feat: better first time downloading, feat: more logging
Build and Publish Container / Publish Linux container (push) Successful in 4m59s

This commit is contained in:
2026-10-05 14:23:25 -04:00
parent fa6eb856dc
commit 86b8ab9c75
14 changed files with 504 additions and 70 deletions
+2
View File
@@ -17,6 +17,8 @@ STEAMCMD_BATCH_SIZE=25
STEAMCMD_TIMEOUT_SECONDS=1800 STEAMCMD_TIMEOUT_SECONDS=1800
REFRESH_INTERVAL_SECONDS=3600 REFRESH_INTERVAL_SECONDS=3600
REFRESH_ON_STARTUP=true REFRESH_ON_STARTUP=true
# Hard ceiling for the first full Workshop download (12 hours).
INITIAL_DOWNLOAD_TIMEOUT_SECONDS=43200
MODLIST_GAME_VERSION=1.6 MODLIST_GAME_VERSION=1.6
# Optional bearer token required by POST /api/refresh when set. # Optional bearer token required by POST /api/refresh when set.
PROGRESSION_ADMIN_TOKEN= PROGRESSION_ADMIN_TOKEN=
+2 -1
View File
@@ -27,7 +27,8 @@ ENV PROGRESSION_HOST=0.0.0.0 \
STEAMCMD_PATH=/opt/steamcmd/steamcmd.sh \ STEAMCMD_PATH=/opt/steamcmd/steamcmd.sh \
STEAMCMD_WORKSHOP_DIR=/opt/steamcmd/steamapps/workshop/content/294100 \ STEAMCMD_WORKSHOP_DIR=/opt/steamcmd/steamapps/workshop/content/294100 \
REFRESH_INTERVAL_SECONDS=3600 \ REFRESH_INTERVAL_SECONDS=3600 \
REFRESH_ON_STARTUP=true REFRESH_ON_STARTUP=true \
INITIAL_DOWNLOAD_TIMEOUT_SECONDS=43200
VOLUME ["/srv/progression/.data", "/opt/steamcmd/steamapps"] VOLUME ["/srv/progression/.data", "/opt/steamcmd/steamapps"]
EXPOSE 8000 EXPOSE 8000
+1
View File
@@ -8,6 +8,7 @@ services:
environment: environment:
PROGRESSION_ADMIN_TOKEN: ${PROGRESSION_ADMIN_TOKEN:-} PROGRESSION_ADMIN_TOKEN: ${PROGRESSION_ADMIN_TOKEN:-}
MODLIST_GAME_VERSION: ${MODLIST_GAME_VERSION:-1.6} MODLIST_GAME_VERSION: ${MODLIST_GAME_VERSION:-1.6}
INITIAL_DOWNLOAD_TIMEOUT_SECONDS: ${INITIAL_DOWNLOAD_TIMEOUT_SECONDS:-43200}
volumes: volumes:
- progression-data:/srv/progression/.data - progression-data:/srv/progression/.data
- progression-workshop:/opt/steamcmd/steamapps - progression-workshop:/opt/steamcmd/steamapps
+2
View File
@@ -20,4 +20,6 @@ STEAMCMD_BATCH_SIZE=25
STEAMCMD_TIMEOUT_SECONDS=1800 STEAMCMD_TIMEOUT_SECONDS=1800
REFRESH_INTERVAL_SECONDS=3600 REFRESH_INTERVAL_SECONDS=3600
REFRESH_ON_STARTUP=true REFRESH_ON_STARTUP=true
# Hard ceiling for the first full Workshop download (12 hours).
INITIAL_DOWNLOAD_TIMEOUT_SECONDS=43200
+5
View File
@@ -15,3 +15,8 @@ docker compose up -d
The SQLite database and Steam Workshop files are stored in named Docker volumes The SQLite database and Steam Workshop files are stored in named Docker volumes
and survive image upgrades. To update later, run the same two commands again. and survive image upgrades. To update later, run the same two commands again.
On a new deployment, the hourly refresh timer stays paused while the first full
Workshop download runs. Its hard limit defaults to 12 hours and can be changed
with `INITIAL_DOWNLOAD_TIMEOUT_SECONDS`. Follow the per-mod title, Workshop ID,
result, and completion percentage in Dockhand or with `docker compose logs -f`.
+1
View File
@@ -23,6 +23,7 @@ services:
STEAMCMD_TIMEOUT_SECONDS: ${STEAMCMD_TIMEOUT_SECONDS:-1800} STEAMCMD_TIMEOUT_SECONDS: ${STEAMCMD_TIMEOUT_SECONDS:-1800}
REFRESH_INTERVAL_SECONDS: ${REFRESH_INTERVAL_SECONDS:-3600} REFRESH_INTERVAL_SECONDS: ${REFRESH_INTERVAL_SECONDS:-3600}
REFRESH_ON_STARTUP: ${REFRESH_ON_STARTUP:-true} REFRESH_ON_STARTUP: ${REFRESH_ON_STARTUP:-true}
INITIAL_DOWNLOAD_TIMEOUT_SECONDS: ${INITIAL_DOWNLOAD_TIMEOUT_SECONDS:-43200}
volumes: volumes:
- progression-data:/srv/progression/.data - progression-data:/srv/progression/.data
- progression-workshop:/opt/steamcmd/steamapps - progression-workshop:/opt/steamcmd/steamapps
+4 -1
View File
@@ -83,7 +83,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
) )
service = RefreshService(settings, database, runner=runner) service = RefreshService(settings, database, runner=runner)
coordinator = RefreshCoordinator( coordinator = RefreshCoordinator(
service, settings.refresh_interval_seconds, settings.refresh_on_startup service,
settings.refresh_interval_seconds,
settings.refresh_on_startup,
settings.initial_download_timeout_seconds,
) )
@asynccontextmanager @asynccontextmanager
+4
View File
@@ -38,6 +38,7 @@ class Settings:
steamcmd_timeout_seconds: int steamcmd_timeout_seconds: int
refresh_interval_seconds: int refresh_interval_seconds: int
refresh_on_startup: bool refresh_on_startup: bool
initial_download_timeout_seconds: int
game_version: str game_version: str
admin_token: str | None admin_token: str | None
collections: dict[str, str] collections: dict[str, str]
@@ -71,6 +72,9 @@ class Settings:
60, int(os.getenv("REFRESH_INTERVAL_SECONDS", "3600")) 60, int(os.getenv("REFRESH_INTERVAL_SECONDS", "3600"))
), ),
refresh_on_startup=_as_bool(os.getenv("REFRESH_ON_STARTUP"), True), refresh_on_startup=_as_bool(os.getenv("REFRESH_ON_STARTUP"), True),
initial_download_timeout_seconds=max(
60, int(os.getenv("INITIAL_DOWNLOAD_TIMEOUT_SECONDS", "43200"))
),
game_version=os.getenv("MODLIST_GAME_VERSION", "1.6").strip() or "1.6", game_version=os.getenv("MODLIST_GAME_VERSION", "1.6").strip() or "1.6",
admin_token=os.getenv("PROGRESSION_ADMIN_TOKEN") or None, admin_token=os.getenv("PROGRESSION_ADMIN_TOKEN") or None,
collections={ collections={
+233 -9
View File
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
import time import time
import uuid import uuid
from collections.abc import Callable
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
@@ -12,6 +14,14 @@ from .steam_api import SteamWorkshopClient, WorkshopDetails
from .workshop import SteamCmdRunner, directory_size, parse_about_xml, utc_now from .workshop import SteamCmdRunner, directory_size, parse_about_xml, utc_now
logger = logging.getLogger("uvicorn.error")
ProgressCallback = Callable[[dict[str, Any]], None]
class InitialDownloadTimeout(RuntimeError):
pass
def _batches(values: list[str], size: int) -> list[list[str]]: def _batches(values: list[str], size: int) -> list[list[str]]:
return [values[index : index + size] for index in range(0, len(values), size)] return [values[index : index + size] for index in range(0, len(values), size)]
@@ -33,6 +43,16 @@ class RefreshService:
settings.steamcmd_timeout_seconds, settings.steamcmd_timeout_seconds,
) )
def needs_initial_download(self) -> bool:
rows = self.database.current_mods()
if not rows:
return True
return any(
row.get("status") != "ready"
or not (self.settings.workshop_content_dir / row["workshop_id"]).is_dir()
for row in rows
)
def _store_metadata( def _store_metadata(
self, self,
detail: WorkshopDetails, detail: WorkshopDetails,
@@ -104,9 +124,22 @@ class RefreshService:
"last_downloaded_at": utc_now() if downloaded else None, "last_downloaded_at": utc_now() if downloaded else None,
} }
def refresh(self, run_id: str, trigger: str) -> None: def refresh(
self,
run_id: str,
trigger: str,
*,
initial_download: bool = False,
overall_timeout_seconds: int | None = None,
progress: ProgressCallback | None = None,
) -> None:
started_at = utc_now() started_at = utc_now()
started = time.monotonic() started = time.monotonic()
deadline = (
started + overall_timeout_seconds
if initial_download and overall_timeout_seconds is not None
else None
)
self.database.create_run(run_id, trigger, started_at) self.database.create_run(run_id, trigger, started_at)
metrics: dict[str, Any] = { metrics: dict[str, Any] = {
"collection_items": 0, "collection_items": 0,
@@ -123,7 +156,24 @@ class RefreshService:
"error": None, "error": None,
} }
def report(**values: Any) -> None:
if progress:
progress(values)
def remaining_time() -> float | None:
if deadline is None:
return None
remaining = deadline - time.monotonic()
if remaining <= 0:
raise InitialDownloadTimeout(
"The initial Workshop download reached its "
f"{overall_timeout_seconds}-second limit"
)
return remaining
try: try:
report(stage="collections", completed_mods=0, total_mods=0, percent=0.0)
remaining_time()
collection_ids = list(self.settings.collections.values()) collection_ids = list(self.settings.collections.values())
response = self.steam.get_collections(collection_ids) response = self.steam.get_collections(collection_ids)
named_collections: dict[str, tuple[str, list[str]]] = {} named_collections: dict[str, tuple[str, list[str]]] = {}
@@ -177,6 +227,25 @@ class RefreshService:
self.database.upsert_mods(metadata_rows) self.database.upsert_mods(metadata_rows)
metrics["downloads_requested"] = len(targets) metrics["downloads_requested"] = len(targets)
total_targets = len(targets)
completed_targets = 0
title_by_id = {
workshop_id: details[workshop_id].title
for workshop_id in targets
if workshop_id in details
}
report(
stage="downloading" if targets else "inspecting",
completed_mods=0,
total_mods=total_targets,
percent=100.0 if not targets else 0.0,
)
if initial_download:
logger.info(
"Initial Workshop download started: %d mods, %.1f-hour hard limit",
total_targets,
(overall_timeout_seconds or 0) / 3600,
)
if targets and not self.runner.available(): if targets and not self.runner.available():
reason = ( reason = (
f"SteamCMD was not found at '{self.settings.steamcmd_path}'. " f"SteamCMD was not found at '{self.settings.steamcmd_path}'. "
@@ -195,10 +264,35 @@ class RefreshService:
metrics["downloads_failed"] += 1 metrics["downloads_failed"] += 1
self.database.upsert_mods(inspection_rows) self.database.upsert_mods(inspection_rows)
else: else:
for batch_number, batch in enumerate( download_queue = list(targets)
_batches(targets, self.settings.steamcmd_batch_size), start=1 ready_target_ids: set[str] = set()
): batch_number = 0
result = self.runner.run_batch(batch) pass_number = 1
while download_queue:
batches = _batches(
download_queue, self.settings.steamcmd_batch_size
)
for pass_batch_number, batch in enumerate(batches, start=1):
remaining = remaining_time()
batch_number += 1
logger.info(
"%s pass %d batch %d/%d starting (%d mods): %s",
"Initial Workshop download"
if initial_download
else "Workshop refresh",
pass_number,
pass_batch_number,
len(batches),
len(batch),
", ".join(
f"{title_by_id.get(workshop_id, 'Unknown mod')} "
f"[{workshop_id}]"
for workshop_id in batch
),
)
result = self.runner.run_batch(
batch, timeout_seconds=remaining
)
self.database.add_download_attempt( self.database.add_download_attempt(
{ {
"run_id": run_id, "run_id": run_id,
@@ -226,9 +320,60 @@ class RefreshService:
inspection_rows.append(values) inspection_rows.append(values)
if success: if success:
metrics["downloads_succeeded"] += 1 metrics["downloads_succeeded"] += 1
ready_target_ids.add(workshop_id)
else: else:
metrics["downloads_failed"] += 1 metrics["downloads_failed"] += 1
completed_targets = len(ready_target_ids)
percent = round(
(completed_targets / max(total_targets, 1)) * 100,
1,
)
title = title_by_id.get(workshop_id, "Unknown mod")
outcome = "ready" if success else "failed"
report(
stage="downloading",
completed_mods=completed_targets,
total_mods=total_targets,
percent=percent,
current_workshop_id=workshop_id,
current_title=title,
current_status=outcome,
)
logger.info(
"%s %d/%d (%.1f%%) %s: %s [%s]",
"Initial mod download"
if initial_download
else "Mod refresh",
completed_targets,
total_targets,
percent,
outcome,
title,
workshop_id,
)
self.database.upsert_mods(inspection_rows) self.database.upsert_mods(inspection_rows)
remaining_time()
if not initial_download:
break
download_queue = [
workshop_id
for workshop_id in download_queue
if workshop_id not in ready_target_ids
]
if download_queue:
remaining = remaining_time()
retry_delay = min(30.0, remaining or 30.0)
logger.warning(
"Initial Workshop download pass %d left %d mods; "
"retrying in %.0f seconds",
pass_number,
len(download_queue),
retry_delay,
)
time.sleep(retry_delay)
remaining_time()
pass_number += 1
# Re-inspect ready cached items whose metadata has never been parsed by this backend. # Re-inspect ready cached items whose metadata has never been parsed by this backend.
current = self.database.mod_map(unique_ids) current = self.database.mod_map(unique_ids)
@@ -259,12 +404,37 @@ class RefreshService:
metrics["error"] = ( metrics["error"] = (
f"{metrics['unique_items'] - metrics['ready_items']} items are not ready" f"{metrics['unique_items'] - metrics['ready_items']} items are not ready"
) )
report(
stage=metrics["status"],
completed_mods=completed_targets,
total_mods=total_targets,
percent=round(
(completed_targets / max(total_targets, 1)) * 100, 1
)
if total_targets
else 100.0,
)
except Exception as error: # noqa: BLE001 - the run must always persist a terminal state except Exception as error: # noqa: BLE001 - the run must always persist a terminal state
metrics["status"] = "failed" metrics["status"] = "failed"
metrics["error"] = str(error) metrics["error"] = str(error)
metrics["bytes_after"] = directory_size(self.settings.workshop_content_dir) metrics["bytes_after"] = directory_size(self.settings.workshop_content_dir)
metrics["cache_bytes"] = metrics["bytes_after"] metrics["cache_bytes"] = metrics["bytes_after"]
metrics["bytes_downloaded"] = max(
metrics["bytes_after"] - metrics["bytes_before"], 0
)
report(stage="failed", error=str(error))
logger.error(
"%s failed: %s",
"Initial Workshop download" if initial_download else "Workshop refresh",
error,
)
finally: finally:
if metrics["unique_items"]:
metrics["ready_items"] = sum(
1
for row in self.database.current_mods()
if row.get("status") == "ready"
)
elapsed = max(time.monotonic() - started, 0.001) elapsed = max(time.monotonic() - started, 0.001)
metrics["elapsed_seconds"] = round(elapsed, 3) metrics["elapsed_seconds"] = round(elapsed, 3)
metrics["average_mbps"] = round( metrics["average_mbps"] = round(
@@ -272,20 +442,37 @@ class RefreshService:
) )
metrics["finished_at"] = utc_now() metrics["finished_at"] = utc_now()
self.database.finish_run(run_id, metrics) self.database.finish_run(run_id, metrics)
logger.info(
"%s finished: status=%s, ready=%d/%d, elapsed=%.1fs, "
"cache=%d bytes",
"Initial Workshop download" if initial_download else "Workshop refresh",
metrics["status"],
metrics["ready_items"],
metrics["unique_items"],
elapsed,
metrics["cache_bytes"],
)
class RefreshCoordinator: class RefreshCoordinator:
def __init__( def __init__(
self, service: RefreshService, interval_seconds: int, refresh_on_startup: bool self,
service: RefreshService,
interval_seconds: int,
refresh_on_startup: bool,
initial_download_timeout_seconds: int = 43200,
): ):
self.service = service self.service = service
self.interval_seconds = interval_seconds self.interval_seconds = interval_seconds
self.refresh_on_startup = refresh_on_startup self.refresh_on_startup = refresh_on_startup
self.initial_download_timeout_seconds = initial_download_timeout_seconds
self.current_run_id: str | None = None self.current_run_id: str | None = None
self.next_refresh_at: str | None = None self.next_refresh_at: str | None = None
self._refresh_task: asyncio.Task[None] | None = None self._refresh_task: asyncio.Task[None] | None = None
self._scheduler_task: asyncio.Task[None] | None = None self._scheduler_task: asyncio.Task[None] | None = None
self._stop = asyncio.Event() self._stop = asyncio.Event()
self._active_initial_download = False
self._progress: dict[str, Any] | None = None
async def start(self) -> None: async def start(self) -> None:
if self._scheduler_task: if self._scheduler_task:
@@ -303,19 +490,48 @@ class RefreshCoordinator:
return self.current_run_id or "", False return self.current_run_id or "", False
run_id = uuid.uuid4().hex run_id = uuid.uuid4().hex
self.current_run_id = run_id self.current_run_id = run_id
initial_download = self.service.needs_initial_download()
self._active_initial_download = initial_download
self._progress = {
"stage": "starting",
"completed_mods": 0,
"total_mods": 0,
"percent": 0.0,
}
def update_progress(values: dict[str, Any]) -> None:
self._progress = {**(self._progress or {}), **values}
async def run() -> None: async def run() -> None:
try: try:
await asyncio.to_thread(self.service.refresh, run_id, trigger) await asyncio.to_thread(
self.service.refresh,
run_id,
trigger,
initial_download=initial_download,
overall_timeout_seconds=(
self.initial_download_timeout_seconds
if initial_download
else None
),
progress=update_progress,
)
finally: finally:
self.current_run_id = None self.current_run_id = None
self._active_initial_download = False
self._refresh_task = asyncio.create_task(run()) self._refresh_task = asyncio.create_task(run())
return run_id, True return run_id, True
async def _wait_for_initial_download(self, started: bool) -> None:
if started and self._active_initial_download and self._refresh_task:
self.next_refresh_at = None
await asyncio.shield(self._refresh_task)
async def _scheduler_loop(self) -> None: async def _scheduler_loop(self) -> None:
if self.refresh_on_startup: if self.refresh_on_startup:
await self.trigger("startup") _, started = await self.trigger("startup")
await self._wait_for_initial_download(started)
while not self._stop.is_set(): while not self._stop.is_set():
next_time = datetime.now(timezone.utc) + timedelta( next_time = datetime.now(timezone.utc) + timedelta(
seconds=self.interval_seconds seconds=self.interval_seconds
@@ -324,7 +540,8 @@ class RefreshCoordinator:
try: try:
await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds) await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds)
except asyncio.TimeoutError: except asyncio.TimeoutError:
await self.trigger("scheduled") _, started = await self.trigger("scheduled")
await self._wait_for_initial_download(started)
def state(self) -> dict[str, Any]: def state(self) -> dict[str, Any]:
return { return {
@@ -332,4 +549,11 @@ class RefreshCoordinator:
"current_run_id": self.current_run_id, "current_run_id": self.current_run_id,
"next_refresh_at": self.next_refresh_at, "next_refresh_at": self.next_refresh_at,
"interval_seconds": self.interval_seconds, "interval_seconds": self.interval_seconds,
"initial_download": bool(
self._active_initial_download
and self._refresh_task
and not self._refresh_task.done()
),
"initial_download_timeout_seconds": self.initial_download_timeout_seconds,
"progress": dict(self._progress) if self._progress else None,
} }
+24 -4
View File
@@ -108,9 +108,19 @@ function renderServiceState(data) {
const ready = Number(data.totals?.ready_mods || 0); const ready = Number(data.totals?.ready_mods || 0);
element.className = "service-state"; element.className = "service-state";
if (data.scheduler?.running) { const progress = data.scheduler?.progress || {};
const progressPercent = Number(progress.percent || 0);
if (data.scheduler?.initial_download) {
element.classList.add("working"); element.classList.add("working");
label.textContent = "Updating now"; label.textContent = progress.total_mods
? `Downloading first-time mods · ${Math.round(progressPercent)}%`
: "Downloading first-time mods";
} else if (data.scheduler?.running) {
element.classList.add("working");
label.textContent = progress.total_mods
? `Updating now · ${Math.round(progressPercent)}%`
: "Updating now";
} else if (data.latest_run?.status === "failed") { } else if (data.latest_run?.status === "failed") {
element.classList.add("failed"); element.classList.add("failed");
label.textContent = "Update needs attention"; label.textContent = "Update needs attention";
@@ -166,12 +176,22 @@ function renderDashboard(data) {
} }
const updatedAt = data.rml_updated_at; const updatedAt = data.rml_updated_at;
$("#last-updated").textContent = data.scheduler?.running ? "Updating now" : formatRelativeDate(updatedAt); const progress = data.scheduler?.progress || {};
const initialDownload = Boolean(data.scheduler?.initial_download);
$("#last-updated").textContent = initialDownload
? `Downloading ${Math.round(Number(progress.percent || 0))}%`
: (data.scheduler?.running ? "Updating now" : formatRelativeDate(updatedAt));
$("#last-updated").title = formatDate(updatedAt); $("#last-updated").title = formatDate(updatedAt);
state.rmlReady = total > 0 && ready === total && !data.scheduler?.running; state.rmlReady = total > 0 && ready === total && !data.scheduler?.running;
const nextRefresh = data.scheduler?.next_refresh_at; const nextRefresh = data.scheduler?.next_refresh_at;
if (data.scheduler?.running) { if (initialDownload) {
const completed = Number(progress.completed_mods || 0).toLocaleString();
const target = Number(progress.total_mods || 0).toLocaleString();
$("#update-detail").textContent = progress.total_mods
? `First-time download: ${completed} of ${target} mods. Hourly checks begin after it finishes.`
: "Preparing the first-time Workshop download. Hourly checks are paused.";
} else if (data.scheduler?.running) {
$("#update-detail").textContent = "The hourly Workshop refresh is currently running."; $("#update-detail").textContent = "The hourly Workshop refresh is currently running.";
} else if (!state.rmlReady) { } else if (!state.rmlReady) {
$("#update-detail").textContent = "The current RML is being prepared from the latest Workshop data."; $("#update-detail").textContent = "The current RML is being prepared from the latest Workshop data.";
+3 -4
View File
@@ -90,14 +90,13 @@
</main> </main>
<footer> <footer>
<div class="footer-meta">
<span id="footer-status">Workshop data refreshed every hour</span> <span id="footer-status">Workshop data refreshed every hour</span>
<div class="footer-credits">
<span class="art-credit">Art by Ferny</span> <span class="art-credit">Art by Ferny</span>
</div>
<a class="hr-link" href="https://hudsonriggs.systems" target="_blank" rel="noopener noreferrer" aria-label="Visit Hudson Riggs Systems"> <a class="hr-link" href="https://hudsonriggs.systems" target="_blank" rel="noopener noreferrer" aria-label="Visit Hudson Riggs Systems">
<span>Hudson Riggs Systems</span> <img src="/art/hudsonriggssystems.png" alt="Hudson Riggs Systems">
<img src="/art/hudsonriggssystems.png" alt="">
</a> </a>
</div>
</footer> </footer>
</div> </div>
+32 -15
View File
@@ -48,18 +48,28 @@
html { min-width: 320px; background: var(--bg); } html { min-width: 320px; background: var(--bg); }
body { body {
position: relative;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
background: background: #0b0b0c url("/art/Desolation.png") center / cover fixed no-repeat;
radial-gradient(circle at 85% 8%, rgba(84, 114, 79, .12), transparent 27rem),
linear-gradient(180deg, #171717 0, #101214 100%);
color: var(--text); color: var(--text);
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 16px; font-size: 16px;
line-height: 1.5; line-height: 1.5;
} }
body::before {
content: "";
position: fixed;
z-index: 0;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at center, rgba(21, 21, 21, .08), rgba(21, 21, 21, .76) 72%),
linear-gradient(rgba(21, 21, 21, .36), rgba(21, 21, 21, .66));
}
button, input { font: inherit; } button, input { font: inherit; }
button { color: inherit; } button { color: inherit; }
img { display: block; max-width: 100%; } img { display: block; max-width: 100%; }
@@ -117,6 +127,8 @@ h1, h2, p { margin: 0; }
.is-ready .intro { opacity: 0; visibility: hidden; } .is-ready .intro { opacity: 0; visibility: hidden; }
.page-shell { .page-shell {
position: relative;
z-index: 1;
width: min(1220px, calc(100% - 48px)); width: min(1220px, calc(100% - 48px));
margin: 0 auto; margin: 0 auto;
padding-bottom: 26px; padding-bottom: 26px;
@@ -146,9 +158,13 @@ h1, h2, p { margin: 0; }
font-size: .78rem; font-size: .78rem;
font-weight: 800; font-weight: 800;
letter-spacing: .08em; letter-spacing: .08em;
line-height: 1.3;
text-align: right;
text-transform: uppercase; text-transform: uppercase;
} }
.service-state span:last-child { max-width: 300px; }
.state-dot { .state-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
@@ -352,20 +368,22 @@ footer {
font-size: .76rem; font-size: .76rem;
} }
.footer-meta { display: flex; align-items: center; gap: 10px; } .footer-credits { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.art-credit { padding: 4px 8px; border: 1px solid #484848; border-radius: 999px; color: #999; font-family: var(--font-serif); font-style: italic; } .art-credit { padding: 4px 8px; border: 1px solid #484848; border-radius: 999px; color: #999; font-family: var(--font-serif); font-style: italic; }
.hr-link { .hr-link {
display: inline-flex; width: 42px;
height: 42px;
display: block;
align-items: center; align-items: center;
gap: 10px; flex: 0 0 auto;
color: #f16365;
text-decoration: none; text-decoration: none;
transition: color .16s ease, transform .16s ease; transition: transform .16s ease, filter .16s ease;
} }
.hr-link:hover { color: #ff7b7d; transform: translateY(-1px); } .hr-link:hover { transform: translateY(-1px); filter: brightness(1.12); }
.hr-link img { width: 42px; height: 42px; object-fit: contain; } .hr-link:focus-visible { outline: 3px solid rgba(241, 99, 101, .28); outline-offset: 3px; border-radius: 5px; }
.hr-link img { width: 100%; height: 100%; object-fit: contain; }
.wizard { .wizard {
width: min(820px, calc(100% - 30px)); width: min(820px, calc(100% - 30px));
@@ -578,9 +596,9 @@ footer {
@media (max-width: 700px) { @media (max-width: 700px) {
.page-shell { width: min(calc(100% - 24px), 1220px); } .page-shell { width: min(calc(100% - 24px), 1220px); }
.site-header { min-height: 78px; } .site-header { min-height: 78px; gap: 12px; }
.brand { width: min(67vw, 330px); } .brand { width: min(59vw, 330px); }
.service-state { font-size: 0; } .service-state { max-width: 36vw; font-size: .62rem; }
.hero { grid-template-columns: 1fr; } .hero { grid-template-columns: 1fr; }
.hero-copy { padding: 30px 24px; } .hero-copy { padding: 30px 24px; }
.hero-art { min-height: 180px; border-top: 1px solid rgba(255,255,255,.09); border-left: 0; } .hero-art { min-height: 180px; border-top: 1px solid rgba(255,255,255,.09); border-left: 0; }
@@ -588,7 +606,6 @@ footer {
.freshness { grid-template-columns: 1fr; } .freshness { grid-template-columns: 1fr; }
.freshness small { grid-column: auto; } .freshness small { grid-column: auto; }
footer { align-items: flex-start; gap: 10px; } footer { align-items: flex-start; gap: 10px; }
.hr-link span { display: none; }
.wizard { width: min(100% - 18px, 820px); max-height: 94vh; } .wizard { width: min(100% - 18px, 820px); max-height: 94vh; }
.wizard-header { min-height: 66px; padding-left: 18px; } .wizard-header { min-height: 66px; padding-left: 18px; }
.wizard-progress { padding: 16px 16px 14px; } .wizard-progress { padding: 16px 16px 14px; }
@@ -611,7 +628,7 @@ footer {
.count-grid { grid-template-columns: 1fr; } .count-grid { grid-template-columns: 1fr; }
.count-card { min-height: 100px; } .count-card { min-height: 100px; }
.start-button { min-height: 78px; padding-left: 16px; } .start-button { min-height: 78px; padding-left: 16px; }
.footer-meta { align-items: flex-start; flex-direction: column; gap: 5px; } .footer-credits { gap: 6px; }
.wizard-actions { display: grid; grid-template-columns: 1fr 1.5fr; } .wizard-actions { display: grid; grid-template-columns: 1fr 1.5fr; }
.button, .button-download { min-width: 0; } .button, .button-download { min-width: 0; }
} }
+10 -2
View File
@@ -4,6 +4,7 @@ import re
import shutil import shutil
import subprocess import subprocess
import time import time
from math import ceil
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -115,7 +116,11 @@ class SteamCmdRunner:
def available(self) -> bool: def available(self) -> bool:
return self.executable_path() is not None return self.executable_path() is not None
def run_batch(self, workshop_ids: list[str]) -> SteamCmdBatchResult: def run_batch(
self,
workshop_ids: list[str],
timeout_seconds: float | None = None,
) -> SteamCmdBatchResult:
executable = self.executable_path() executable = self.executable_path()
if not executable: if not executable:
raise FileNotFoundError( raise FileNotFoundError(
@@ -142,6 +147,9 @@ class SteamCmdRunner:
exit_code: int | None = None exit_code: int | None = None
status = "completed" status = "completed"
output = "" output = ""
timeout = self.timeout_seconds
if timeout_seconds is not None:
timeout = max(1, min(timeout, ceil(timeout_seconds)))
try: try:
completed = subprocess.run( completed = subprocess.run(
command, command,
@@ -150,7 +158,7 @@ class SteamCmdRunner:
text=True, text=True,
encoding="utf-8", encoding="utf-8",
errors="replace", errors="replace",
timeout=self.timeout_seconds, timeout=timeout,
check=False, check=False,
shell=False, shell=False,
) )
+152 -5
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import tempfile import tempfile
import threading
import unittest import unittest
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
@@ -19,7 +21,7 @@ from progression_backend.modlist import (
parse_active_mods, parse_active_mods,
workshop_items, workshop_items,
) )
from progression_backend.refresh import RefreshService from progression_backend.refresh import RefreshCoordinator, RefreshService
from progression_backend.steam_api import WorkshopDetails from progression_backend.steam_api import WorkshopDetails
from progression_backend.workshop import SteamCmdBatchResult, parse_about_xml, utc_now from progression_backend.workshop import SteamCmdBatchResult, parse_about_xml, utc_now
@@ -45,11 +47,15 @@ class FakeSteam:
class FakeRunner: class FakeRunner:
def __init__(self, content_dir: Path): def __init__(self, content_dir: Path):
self.content_dir = content_dir self.content_dir = content_dir
self.last_timeout_seconds: float | None = None
def available(self) -> bool: def available(self) -> bool:
return True return True
def run_batch(self, workshop_ids: list[str]) -> SteamCmdBatchResult: def run_batch(
self, workshop_ids: list[str], timeout_seconds: float | None = None
) -> SteamCmdBatchResult:
self.last_timeout_seconds = timeout_seconds
for workshop_id in workshop_ids: for workshop_id in workshop_ids:
about = self.content_dir / workshop_id / "About" about = self.content_dir / workshop_id / "About"
about.mkdir(parents=True) about.mkdir(parents=True)
@@ -72,6 +78,32 @@ class FakeRunner:
) )
class FailingRunner:
def __init__(self) -> None:
self.calls = 0
self.last_timeout_seconds: float | None = None
def available(self) -> bool:
return True
def run_batch(
self, workshop_ids: list[str], timeout_seconds: float | None = None
) -> SteamCmdBatchResult:
self.calls += 1
self.last_timeout_seconds = timeout_seconds
timestamp = utc_now()
return SteamCmdBatchResult(
workshop_ids=workshop_ids,
started_at=timestamp,
finished_at=timestamp,
elapsed_seconds=0.001,
exit_code=1,
status="failed",
failed_ids=set(workshop_ids),
output_tail="Download failed",
)
class BackendTests(unittest.TestCase): class BackendTests(unittest.TestCase):
@patch("progression_backend.app.requests.get") @patch("progression_backend.app.requests.get")
def test_latest_windows_release_includes_prereleases(self, get: Mock) -> None: def test_latest_windows_release_includes_prereleases(self, get: Mock) -> None:
@@ -182,6 +214,7 @@ class BackendTests(unittest.TestCase):
steamcmd_timeout_seconds=60, steamcmd_timeout_seconds=60,
refresh_interval_seconds=3600, refresh_interval_seconds=3600,
refresh_on_startup=False, refresh_on_startup=False,
initial_download_timeout_seconds=43200,
game_version="1.6", game_version="1.6",
admin_token=None, admin_token=None,
collections={"Core": "1", "Content": "2", "Cosmetics": "3"}, collections={"Core": "1", "Content": "2", "Cosmetics": "3"},
@@ -189,13 +222,21 @@ class BackendTests(unittest.TestCase):
) )
database = Database(settings.database_path) database = Database(settings.database_path)
database.initialize() database.initialize()
runner = FakeRunner(settings.workshop_content_dir)
service = RefreshService( service = RefreshService(
settings, settings,
database, database,
steam=FakeSteam(), steam=FakeSteam(),
runner=FakeRunner(settings.workshop_content_dir), runner=runner,
)
progress_events: list[dict] = []
service.refresh(
"test-run",
"test",
initial_download=True,
overall_timeout_seconds=10,
progress=progress_events.append,
) )
service.refresh("test-run", "test")
run = database.fetch_one( run = database.fetch_one(
"SELECT * FROM refresh_runs WHERE id = ?", ("test-run",) "SELECT * FROM refresh_runs WHERE id = ?", ("test-run",)
@@ -209,6 +250,52 @@ class BackendTests(unittest.TestCase):
self.assertEqual(mod["steam_app_id"], "294100") self.assertEqual(mod["steam_app_id"], "294100")
generated = workshop_items([mod]) generated = workshop_items([mod])
self.assertEqual(generated[0].workshop_id, "123456") self.assertEqual(generated[0].workshop_id, "123456")
self.assertIsNotNone(runner.last_timeout_seconds)
self.assertLessEqual(runner.last_timeout_seconds or 0, 10)
self.assertEqual(progress_events[-1]["stage"], "completed")
self.assertEqual(progress_events[-1]["percent"], 100.0)
def test_initial_download_stops_at_overall_timeout(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
settings = Settings(
host="127.0.0.1",
port=8000,
database_path=root / "progression.db",
steamcmd_path="steamcmd",
workshop_content_dir=root / "workshop" / "294100",
steamcmd_batch_size=25,
steamcmd_timeout_seconds=60,
refresh_interval_seconds=3600,
refresh_on_startup=False,
initial_download_timeout_seconds=43200,
game_version="1.6",
admin_token=None,
collections={"Core": "1", "Content": "2", "Cosmetics": "3"},
static_dir=root,
)
database = Database(settings.database_path)
database.initialize()
runner = FailingRunner()
service = RefreshService(
settings, database, steam=FakeSteam(), runner=runner # type: ignore[arg-type]
)
service.refresh(
"timeout-run",
"startup",
initial_download=True,
overall_timeout_seconds=0.25, # type: ignore[arg-type]
)
run = database.fetch_one(
"SELECT * FROM refresh_runs WHERE id = ?", ("timeout-run",)
)
self.assertIsNotNone(run)
self.assertEqual(run["status"], "failed")
self.assertIn("initial Workshop download reached", run["error"])
self.assertEqual(runner.calls, 1)
self.assertLessEqual(runner.last_timeout_seconds or 0, 0.25)
def test_api_serves_dashboard_and_reports_empty_modlist(self) -> None: def test_api_serves_dashboard_and_reports_empty_modlist(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -224,6 +311,7 @@ class BackendTests(unittest.TestCase):
steamcmd_timeout_seconds=60, steamcmd_timeout_seconds=60,
refresh_interval_seconds=3600, refresh_interval_seconds=3600,
refresh_on_startup=False, refresh_on_startup=False,
initial_download_timeout_seconds=43200,
game_version="1.6", game_version="1.6",
admin_token=None, admin_token=None,
collections={"Core": "1", "Content": "2", "Cosmetics": "3"}, collections={"Core": "1", "Content": "2", "Cosmetics": "3"},
@@ -233,7 +321,13 @@ class BackendTests(unittest.TestCase):
self.assertEqual(client.get("/").status_code, 200) self.assertEqual(client.get("/").status_code, 200)
self.assertEqual(client.get("/art/GameTitle.png").status_code, 200) self.assertEqual(client.get("/art/GameTitle.png").status_code, 200)
self.assertEqual(client.get("/api/health").json()["status"], "ok") self.assertEqual(client.get("/api/health").json()["status"], "ok")
self.assertIn("rml_updated_at", client.get("/api/status").json()) dashboard = client.get("/api/status").json()
self.assertIn("rml_updated_at", dashboard)
self.assertIn("initial_download", dashboard["scheduler"])
self.assertEqual(
dashboard["scheduler"]["initial_download_timeout_seconds"],
43200,
)
response = client.get("/api/modlists/vanilla") response = client.get("/api/modlists/vanilla")
self.assertEqual(response.status_code, 409) self.assertEqual(response.status_code, 409)
@@ -252,5 +346,58 @@ class BackendTests(unittest.TestCase):
self.assertEqual(response.headers["location"], release_url) self.assertEqual(response.headers["location"], release_url)
class SlowInitialService:
def __init__(self) -> None:
self.started = threading.Event()
self.release = threading.Event()
self.needs_download = True
self.received_timeout: int | None = None
def needs_initial_download(self) -> bool:
return self.needs_download
def refresh(self, run_id: str, trigger: str, **options: object) -> None:
self.received_timeout = options.get("overall_timeout_seconds") # type: ignore[assignment]
progress = options.get("progress")
if callable(progress):
progress(
{
"stage": "downloading",
"completed_mods": 1,
"total_mods": 4,
"percent": 25.0,
}
)
self.started.set()
self.release.wait(timeout=2)
self.needs_download = False
class CoordinatorTests(unittest.IsolatedAsyncioTestCase):
async def test_initial_download_pauses_hourly_scheduler(self) -> None:
service = SlowInitialService()
coordinator = RefreshCoordinator(
service, 3600, True, initial_download_timeout_seconds=43200 # type: ignore[arg-type]
)
await coordinator.start()
started = await asyncio.to_thread(service.started.wait, 1)
self.assertTrue(started)
active = coordinator.state()
self.assertTrue(active["initial_download"])
self.assertIsNone(active["next_refresh_at"])
self.assertEqual(active["progress"]["percent"], 25.0)
self.assertEqual(service.received_timeout, 43200)
service.release.set()
if coordinator._refresh_task:
await asyncio.wait_for(coordinator._refresh_task, timeout=1)
await asyncio.sleep(0.01)
completed = coordinator.state()
self.assertFalse(completed["initial_download"])
self.assertIsNotNone(completed["next_refresh_at"])
await coordinator.stop()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()