left4me/l4d2web/services/global_overlays.py
mwiegand 92d6ebbe82
feat(l4d2-web): managed global map overlays with daily refresh
Adds two managed system overlays (l4d2center-maps, cedapug-maps) that
fetch curated map archives from upstream sources and reconcile addons
symlinks for non-Steam maps. A daily systemd timer enqueues a coalesced
refresh_global_overlays worker job; downloads, extraction, and rebuilds
run in the existing job worker and surface in the job log UI.

Schema: GlobalOverlaySource / GlobalOverlayItem / GlobalOverlayItemFile
plus nullable Job.user_id so system jobs render as "system" in the UI.
The new builder reconciles symlinks against the per-source vpk cache
and leaves foreign symlinks untouched. Initialize-time guard refuses
to mount a partial overlay if any expected vpk is missing from cache.

Refresh service uses shutil.move to handle EXDEV when /tmp and the
cache live on different filesystems.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:05:14 +02:00

112 lines
3.3 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import os
from sqlalchemy import select
from sqlalchemy.orm import Session
from l4d2host.paths import get_left4me_root
from l4d2web.models import GlobalOverlaySource, Job, Overlay
from l4d2web.services.overlay_creation import generate_overlay_path
@dataclass(frozen=True)
class ManagedGlobalOverlay:
name: str
overlay_type: str
source_type: str
source_url: str
GLOBAL_OVERLAYS = (
ManagedGlobalOverlay(
name="l4d2center-maps",
overlay_type="l4d2center_maps",
source_type="l4d2center_csv",
source_url="https://l4d2center.com/maps/servers/index.csv",
),
ManagedGlobalOverlay(
name="cedapug-maps",
overlay_type="cedapug_maps",
source_type="cedapug_custom_page",
source_url="https://cedapug.com/custom",
),
)
MANAGED_GLOBAL_OVERLAY_TYPES = {overlay.overlay_type for overlay in GLOBAL_OVERLAYS}
USER_CREATABLE_TYPES = {"workshop"}
ADMIN_CREATABLE_TYPES = {"external", "workshop"}
def is_creatable_overlay_type(overlay_type: str, *, admin: bool) -> bool:
allowed = ADMIN_CREATABLE_TYPES if admin else USER_CREATABLE_TYPES
return overlay_type in allowed
def ensure_global_overlays(session: Session) -> set[str]:
created_sources: set[str] = set()
for managed in GLOBAL_OVERLAYS:
overlay = session.scalar(
select(Overlay).where(Overlay.name == managed.name, Overlay.user_id.is_(None))
)
overlay_created = overlay is None
if overlay is None:
overlay = Overlay(name=managed.name, path="", type=managed.overlay_type, user_id=None)
session.add(overlay)
session.flush()
overlay.path = generate_overlay_path(overlay.id)
else:
overlay.type = managed.overlay_type
overlay.user_id = None
if not overlay.path:
overlay.path = generate_overlay_path(overlay.id)
target = get_left4me_root() / "overlays" / overlay.path
os.makedirs(target, exist_ok=not overlay_created)
source = session.scalar(
select(GlobalOverlaySource).where(GlobalOverlaySource.source_key == managed.name)
)
if source is None:
source = GlobalOverlaySource(
overlay_id=overlay.id,
source_key=managed.name,
source_type=managed.source_type,
source_url=managed.source_url,
)
session.add(source)
created_sources.add(managed.name)
else:
source.overlay_id = overlay.id
source.source_type = managed.source_type
source.source_url = managed.source_url
session.flush()
return created_sources
def enqueue_refresh_global_overlays(session: Session, *, user_id: int | None) -> Job:
existing = session.scalar(
select(Job)
.where(
Job.operation == "refresh_global_overlays",
Job.state.in_({"queued", "running", "cancelling"}),
)
.order_by(Job.created_at, Job.id)
)
if existing is not None:
return existing
job = Job(
user_id=user_id,
server_id=None,
overlay_id=None,
operation="refresh_global_overlays",
state="queued",
)
session.add(job)
session.flush()
return job