Files
sortof/api/app.py

1481 lines
58 KiB
Python

"""sortof FastAPI service.
Loopback-only (bound 127.0.0.1) HTTP API that accepts a Steam Workshop input
blob, looks up cached mod metadata in Postgres, runs mlos_sort over the
hit subset, and returns SORTOF_DATA-shaped JSON. Cache misses are queued
into download_jobs for the worker to fulfill.
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, Dict, List, Optional
from uuid import UUID
import httpx
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
import adapters
import db
import expansion
import jobs
import steam
from mlos_sort import ModInfo, parse_sorting_rules, sort_mods
from parse import parse_with_collections, parse_workshop_input
# Load .env before reading SORTOF_CORS_ORIGINS so dev/manual runs (no systemd
# EnvironmentFile) still pick it up. systemd-injected env wins by default
# because load_dotenv does not override pre-set variables.
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
_cors_raw = os.getenv("SORTOF_CORS_ORIGINS", "").strip()
CORS_ORIGINS = [o.strip() for o in _cors_raw.split(",") if o.strip()] or [
"http://127.0.0.1:8801",
]
MAX_IDS = 500
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
log = logging.getLogger("sortof.api")
# Path to the human-edited PZ version-label file. Checked into the repo so
# updates are visible in git history (vs. a hidden .env). The API loads it
# at startup; bumping the labels requires a `systemctl restart sortof-api`.
_PZ_VERSIONS_PATH = Path(__file__).resolve().parent.parent / "data" / "pz_versions.json"
def _load_pz_versions() -> Dict[str, str]:
"""Read pz_versions.json and return {branch_key: human_label}. Drops
any keys starting with '_' (those are inline documentation)."""
import json
try:
raw = json.loads(_PZ_VERSIONS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
log.warning("pz_versions.json load failed: %s", e)
return {}
return {k: v for k, v in raw.items() if not k.startswith("_") and isinstance(v, str)}
@asynccontextmanager
async def lifespan(app: FastAPI):
pool = await db.create_pool()
http = httpx.AsyncClient(timeout=30.0)
app.state.db = pool
app.state.http = http
app.state.pending_tasks = set()
app.state.pz_versions = _load_pz_versions()
async with pool.acquire() as conn:
n_reaped = await jobs.sweep_stale_expansions(conn)
if n_reaped:
log.info("lifespan startup: reaped %d stale expansion job(s)", n_reaped)
log.info(
"sortof.api started (pz_versions: %s)",
", ".join(f"{k}={v}" for k, v in app.state.pz_versions.items()) or "(none)",
)
try:
yield
finally:
await http.aclose()
await pool.close()
log.info("sortof.api stopped")
app = FastAPI(title="sortof API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=False,
allow_methods=["POST", "GET", "OPTIONS"],
allow_headers=["Content-Type"],
)
class SortRequest(BaseModel):
input: str = Field(default="")
rules: Optional[str] = Field(default=None)
# Spec C §2: PZ build context. "B41" or "B42"; default "B42" when missing
# or unrecognized. Drives Rule A (build-aware default) in adapters.
pz_build: Optional[str] = Field(default=None)
class ResortRequest(BaseModel):
selected_mod_ids: List[str] = Field(default_factory=list)
# Same semantics as SortRequest.pz_build. Forwarded so resort can emit
# build-mismatch warnings (the user's pzBuild state on the frontend
# doesn't change between /api/sort and /api/resort, but resort runs in
# isolation server-side without the original job's pz_build).
pz_build: Optional[str] = Field(default=None)
# Original /api/sort input wsids (the user's "subscription set").
# Resort needs them for wsid-keyed warnings — build-mismatch et al.
# are per-wsid, but resort only knows mod_ids, and mod_id ↔ wsid
# isn't 1:1 when a mod_id has been evicted to a different wsid in the
# cache (common for B41 ↔ B42 pairs that share a mod_id).
input_wsids: List[str] = Field(default_factory=list)
def _strip_path_prefix(deps) -> List[str]:
"""Defensively normalize dep names. Strips leading backslashes (B42 path
syntax: `\\StarlitLibrary` -> `StarlitLibrary`) and trims whitespace.
Worker._split_csv applies this at parse time, but ~14 mod_parsed rows
in the live DB were written before that fix landed and will only refresh
when their wsid's time_updated advances on Steam. Normalizing here keeps
missing-dep matching correct against legacy rows."""
out: List[str] = []
for d in (deps or []):
s = (d or "").strip().lstrip("\\")
if s:
out.append(s)
return out
def _inject_addon_loadafter(mods: List[ModInfo]) -> None:
"""For each multi-mod wsid that has both addons and primaries, append the
primary's mod_id to each addon's `loadAfter`. The topo sort honours
loadAfter, so when the user explicitly ticks an addon (via the picker
+ resort), MODS_LINE places it after the primary.
Spec A's _apply_branch_rules drops the addon from MODS_LINE BY DEFAULT
(so this injection is mostly a safety net for the resort path); when
the user adds it back, ordering is preserved without any picker logic.
"""
by_wsid: Dict[str, List[ModInfo]] = {}
for m in mods:
if m.workshop_id:
by_wsid.setdefault(m.workshop_id, []).append(m)
for group in by_wsid.values():
if len(group) < 2:
continue
addons = [m for m in group if m.is_addon]
primaries = [m for m in group if not m.is_addon]
if not addons or not primaries:
continue
# Pick a single primary to anchor against. Most addon wsids have
# exactly one main mod; if there are multiple, alphabetical picks
# a stable target. The order between primaries themselves stays
# however the topo sort decides.
primary_id = sorted(p.id for p in primaries)[0]
for addon in addons:
if primary_id not in addon.loadAfter:
addon.loadAfter = list(addon.loadAfter) + [primary_id]
def _row_to_modinfo(r) -> ModInfo:
"""Build a ModInfo from a mod_parsed row. Used by both /api/sort and
/api/resort; columns must match the SELECT in both endpoints."""
return ModInfo(
id=r["mod_id"],
name=r["name"] or r["mod_id"],
workshop_id=r["workshop_id"],
category=r["category"] or "undefined",
requirements=_strip_path_prefix(r["requirements"]),
loadAfter=_strip_path_prefix(r["load_after"]),
loadBefore=_strip_path_prefix(r["load_before"]),
incompatibleMods=_strip_path_prefix(r["incompatible_mods"]),
loadFirst=r["load_first"] or "off",
loadLast=r["load_last"] or "off",
tags=list(r["tags"] or []),
maps=list(r["maps"] or []),
is_addon=bool(r["is_addon"]) if "is_addon" in r else False,
workshop_tags=list(r["workshop_tags"] or []) if "workshop_tags" in r else [],
)
def _ordered_wsids_line(payload: Dict[str, Any], all_wsids: List[str]) -> str:
"""Compose WORKSHOP_ITEMS_LINE: load-order wsids that have at least one
mod in SORTED_ORDER, then append every other input wsid (unknown,
non_mod, all-branches-deselected) in input order. Spec A §8 ownership
is preserved (set unchanged), Spec C §2 behavior is honored (order
follows the current sort).
Returns a string with trailing ';' to match the existing PZ ini convention.
"""
sorted_order = payload.get("SORTED_ORDER", []) or []
mod_to_wsid: Dict[str, str] = {
entry["modId"]: entry.get("wsid") or ""
for entry in (payload.get("MOD_DB") or [])
}
seen: set = set()
ordered: List[str] = []
for mid in sorted_order:
w = mod_to_wsid.get(mid)
if w and w not in seen:
seen.add(w)
ordered.append(w)
for w in (all_wsids or []):
if w and w not in seen:
seen.add(w)
ordered.append(w)
return ";".join(ordered) + ";" if ordered else ""
async def _lookup_wsids_for_missing(
conn,
mlos_warnings: Dict[str, Any],
pz_build: Optional[str] = None,
) -> Dict[str, str]:
"""Resolve missing-requirement mod_ids to wsids via mod_parsed cache.
Returns {mod_id -> workshop_id} for any missing dep we've previously
cached. Used to render an [add to list] action button next to the
'missing' warning. Unknown deps just get no button.
pz_build filters out wrong-build resolutions: if the user is on B42 and
the only cached wsid for `dep` is tagged `Build 41` only, we DROP the
suggestion. Adding it would just trigger a build-mismatch warning;
worse, in cases like `TacHold requires modoptions` where the author's
`require=` is over-declared, the suggestion misleads the user toward
a B41 mod that doesn't actually need to be in the load order.
"""
missing = mlos_warnings.get("missing_requirements") or {}
if not missing:
return {}
wanted: set = set()
for deps in missing.values():
for d in deps:
if d:
wanted.add(d)
if not wanted:
return {}
rows = await conn.fetch(
"""
SELECT DISTINCT ON (mp.mod_id) mp.mod_id, mp.workshop_id, wm.tags
FROM mod_parsed mp
JOIN workshop_meta wm ON wm.workshop_id = mp.workshop_id
WHERE mp.mod_id = ANY($1::text[])
ORDER BY mp.mod_id, mp.parsed_at_time_updated DESC
""",
list(wanted),
)
target_tag = "Build 42" if pz_build == "B42" else (
"Build 41" if pz_build == "B41" else None
)
other_tag = "Build 41" if target_tag == "Build 42" else (
"Build 42" if target_tag == "Build 41" else None
)
out: Dict[str, str] = {}
for r in rows:
if target_tag and other_tag:
tags = list(r["tags"] or [])
if other_tag in tags and target_tag not in tags:
# Wrong-build only — adding it would just trigger a
# build-mismatch. Skip the suggestion.
continue
out[r["mod_id"]] = r["workshop_id"]
return out
# Hand-curated build-version pairs where the B41↔B42 sibling renamed its
# mod_id (so the auto-discovery via shared mod_id can't find it). Bidirectional
# - if the user submits either side, the OTHER side is offered. Add entries as
# you encounter them; the worker's mod_id-based detection covers anything that
# kept the same mod_id across builds.
MANUAL_BUILD_PAIRS: Dict[str, str] = {
# truemusic (B41) ↔ TrueMoozic (B42). Author renamed mod_id between
# builds; B42 wsid bundles a TMMumble addon as a second mod_id.
"2613146550": "3632610172",
"3632610172": "2613146550",
}
async def _find_swap_candidate(
conn,
wsid: str,
target_tag: str,
exclude_wsids: List[str],
) -> Optional[Dict[str, str]]:
"""Find a sibling Workshop ID that publishes any of `wsid`'s mod_ids and
is tagged for `target_tag` (typically the build the user picked).
Used to attach a "swap to <other>" action onto build-mismatch warnings.
Many mod authors maintain two separate wsids — one B41, one B42 — that
share the same mod_id (e.g. tsarslib at 2392709985 and 3402491515).
Cross-referencing via mod_id finds those pairs without an explicit map.
Sources for "wsid X's mod_ids":
1. mod_parsed (current owner).
2. mod_id_conflicts left/right side (X has been an evictor or evicted
party for some mod_id, meaning it once owned that mod_id).
3. MANUAL_BUILD_PAIRS for cases where the author renamed the mod_id.
Returns {wsid, title} or None if no usable sibling found.
"""
# Manual override first: explicit cross-build pairs we curate by hand.
paired = MANUAL_BUILD_PAIRS.get(wsid)
if paired and paired not in set(exclude_wsids):
row = await conn.fetchrow(
"""
SELECT workshop_id, title FROM workshop_meta
WHERE workshop_id = $1 AND $2 = ANY(tags)
""",
paired, target_tag,
)
if row:
return {"wsid": row["workshop_id"], "title": row["title"]}
rows = await conn.fetch(
"""
WITH x_mod_ids AS (
SELECT mod_id FROM mod_parsed WHERE workshop_id = $1
UNION
SELECT mod_id FROM mod_id_conflicts
WHERE evicted_wsid = $1 OR evicting_wsid = $1
),
candidates AS (
SELECT DISTINCT workshop_id AS wsid
FROM mod_parsed
WHERE mod_id IN (SELECT mod_id FROM x_mod_ids)
AND workshop_id <> $1
UNION
SELECT DISTINCT evicting_wsid
FROM mod_id_conflicts
WHERE mod_id IN (SELECT mod_id FROM x_mod_ids)
AND evicted_wsid = $1
UNION
SELECT DISTINCT evicted_wsid
FROM mod_id_conflicts
WHERE mod_id IN (SELECT mod_id FROM x_mod_ids)
AND evicting_wsid = $1
)
SELECT c.wsid, wm.title
FROM candidates c
JOIN workshop_meta wm ON wm.workshop_id = c.wsid
WHERE wm.consumer_app_id = 108600
AND $2 = ANY(wm.tags)
AND NOT (wm.workshop_id = ANY($3::text[]))
ORDER BY wm.title
LIMIT 1
""",
wsid, target_tag, list(exclude_wsids),
)
if not rows:
return None
return {"wsid": rows[0]["wsid"], "title": rows[0]["title"]}
# Visible warning order: most actionable first. Build-mismatch leads (lets
# the user resolve build issues before chasing other warnings); next come
# the picker-driving warnings (auto-picked-branch, unmatched-addons) so
# the multi-option selection UI sits at the top of the panel; then the
# duplicate-mod_id / missing / conflict / cycle warnings.
_WARNING_TAG_PRIORITY: Dict[str, int] = {
"build-mismatch": 0,
"auto-picked-branch": 1,
"unmatched-addons": 1,
"duplicate-mod_id": 2,
"missing": 3,
"conflict": 4,
"cycle": 5,
}
def _reorder_warnings(payload: Dict[str, Any]) -> None:
"""Stable-sort `payload['WARNINGS']` by tag priority. Warnings sharing
a priority bucket keep their original relative order."""
warnings = payload.get("WARNINGS") or []
if not warnings:
return
payload["WARNINGS"] = sorted(
warnings,
key=lambda w: _WARNING_TAG_PRIORITY.get(w.get("tag", ""), 99),
)
async def _emit_build_mismatch_warnings(
conn,
payload: Dict[str, Any],
input_wsids: List[str],
pz_build: str,
) -> None:
"""Emit `build-mismatch` warnings for cached input wsids whose Steam-side
Build tags don't include the requested build.
`workshop_meta.tags` is Steam's controlled vocabulary — `Build 41` /
`Build 42` are the canonical signal. Three cases per wsid:
- both builds tagged → multi-build, no warn
- only the OPPOSITE build tagged → wrong build, emit warn
- neither build tagged → author didn't mark, can't tell, no warn
- only the TARGET build tagged → fine, no warn
Multi-branch wsids already get their own `build-mismatch` from
`adapters._apply_branch_rules` (Spec C); skip wsids with an existing
one to avoid double-warning.
"""
if not input_wsids or pz_build not in ("B41", "B42"):
return
rows = await conn.fetch(
"""
SELECT workshop_id, title, tags
FROM workshop_meta
WHERE workshop_id = ANY($1::text[])
""",
list(input_wsids),
)
target_tag = "Build 41" if pz_build == "B41" else "Build 42"
other_tag = "Build 42" if pz_build == "B41" else "Build 41"
existing = payload.get("WARNINGS") or []
already_flagged = {
w.get("wsid") for w in existing
if w.get("tag") == "build-mismatch" and w.get("wsid")
}
new_warnings: List[Dict[str, Any]] = []
for r in rows:
wsid = r["workshop_id"]
if wsid in already_flagged:
continue
tags = list(r["tags"] or [])
if target_tag in tags:
continue # supports the picked build
if other_tag not in tags:
continue # author didn't tag a build, can't tell
title = r["title"] or wsid
warn: Dict[str, Any] = {
"tag": "build-mismatch",
"level": "amber",
"msg": (
f"{title} ({wsid}) is tagged {other_tag} only — "
f"may not work with {target_tag}."
),
"wsid": wsid,
}
actions: List[Dict[str, Any]] = []
# Look for a target-build sibling that shares any of this mod's
# mod_ids (the typical "B41 wsid" + "B42 wsid" pair pattern).
swap = await _find_swap_candidate(
conn, wsid, target_tag, list(input_wsids),
)
if swap:
actions.append({
"type": "swap-wsid",
"from": wsid,
"to": swap["wsid"],
"label": f"swap to {swap['title']}",
})
# Always offer a one-click drop for wrong-build mods. Useful when no
# target-build sibling exists (the swap action is missing) or when
# the user just wants to drop it without searching for a replacement.
actions.append({
"type": "remove-wsid",
"wsid": wsid,
"label": "remove",
})
warn["actions"] = actions
new_warnings.append(warn)
if not new_warnings:
return
# Build-mismatched mods are about to be removed or swapped, so any
# `missing` warnings they sourced are noise: they'd push the user
# toward adding deps for a mod they're about to drop. Filter those
# out, but keep other warnings on the same wsid (auto-picked-branch,
# duplicate-mod_id, etc. are still meaningful).
mismatch_wsids = {w["wsid"] for w in new_warnings}
filtered = [
w for w in existing
if not (w.get("tag") == "missing" and w.get("wsid") in mismatch_wsids)
]
# Build-mismatch entries lead the list so the user resolves the
# build-version question before chasing other warnings.
payload["WARNINGS"] = new_warnings + filtered
async def _augment_with_required_items(
conn,
payload: Dict[str, Any],
input_wsids: List[str],
) -> None:
"""Mutate `payload["WARNINGS"]` in place: for each missing-dep warning
whose source mod has Steam-side Required Items in `workshop_meta`,
append `add-wsid` actions for any required wsid not already in the
user's input or already represented as an action.
Steam's anonymous GetPublishedFileDetails does NOT expose `children`
for individual mods, so the worker scrapes the public Workshop page
and stores the result in `workshop_meta.required_wsids`. This lets us
one-click resolve missing deps that aren't yet in the user's cache —
which is exactly the case the search-workshop fallback handled poorly.
"""
warnings = payload.get("WARNINGS") or []
missing = [w for w in warnings if w.get("tag") == "missing" and w.get("wsid")]
if not missing:
return
src_wsids = list({w["wsid"] for w in missing})
rows = await conn.fetch(
"""
SELECT wm.workshop_id AS src_wsid, req.req_wsid
FROM workshop_meta wm,
unnest(wm.required_wsids) AS req(req_wsid)
WHERE wm.workshop_id = ANY($1::text[])
""",
src_wsids,
)
if not rows:
return
pairs: Dict[str, List[str]] = {}
all_req: set = set()
for r in rows:
pairs.setdefault(r["src_wsid"], []).append(r["req_wsid"])
all_req.add(r["req_wsid"])
# Best-available human label for each required wsid:
# 1. mod_parsed.name (cached + parsed) — preferred
# 2. workshop_meta.title (cached, even if mod hasn't been parsed yet)
# 3. fall back to the bare wsid
label_rows = await conn.fetch(
"""
SELECT wm.workshop_id,
wm.title,
(SELECT mp.name FROM mod_parsed mp
JOIN workshop_meta wm2 ON wm2.workshop_id = mp.workshop_id
WHERE mp.workshop_id = wm.workshop_id
AND mp.parsed_at_time_updated = wm2.time_updated
ORDER BY mp.parsed_at DESC LIMIT 1) AS parsed_name,
(SELECT mp.mod_id FROM mod_parsed mp
JOIN workshop_meta wm2 ON wm2.workshop_id = mp.workshop_id
WHERE mp.workshop_id = wm.workshop_id
AND mp.parsed_at_time_updated = wm2.time_updated
ORDER BY mp.parsed_at DESC LIMIT 1) AS parsed_mod_id
FROM workshop_meta wm
WHERE wm.workshop_id = ANY($1::text[])
""",
list(all_req),
)
labels: Dict[str, Dict[str, Optional[str]]] = {
r["workshop_id"]: {
"title": r["title"],
"mod_id": r["parsed_mod_id"],
"name": r["parsed_name"],
}
for r in label_rows
}
input_set = set(input_wsids or [])
for w in missing:
existing_wsids = {
a.get("wsid") for a in (w.get("actions") or [])
if a.get("type") == "add-wsid" and a.get("wsid")
}
actions = list(w.get("actions") or [])
for req_wsid in pairs.get(w["wsid"], []):
if req_wsid in input_set or req_wsid in existing_wsids:
continue
info = labels.get(req_wsid) or {}
# Prefer the Workshop listing title (one canonical name per wsid)
# over `parsed_name` — multi-branch wsids would otherwise pick an
# arbitrary branch's name, which is confusing. mod_id is a useful
# last resort for compactness; bare wsid only when nothing exists.
label_text = (info.get("title") or info.get("name")
or info.get("mod_id") or req_wsid)
actions.append({
"type": "add-wsid",
"wsid": req_wsid,
"modId": info.get("mod_id") or "",
"label": f"add {label_text}",
})
existing_wsids.add(req_wsid)
if actions:
w["actions"] = actions
async def _lookup_mod_id_conflicts(
conn,
input_wsids: List[str],
) -> List[Dict[str, Any]]:
"""Surface mod_id ownership conflicts where the user's input includes a
wsid that lost a claim on a mod_id to another wsid.
Walks `mod_id_conflicts` (recorded by worker.py at parse-time eviction)
and filters to rows where:
- evicted_wsid is in the user's input, AND
- the evicted wsid hasn't subsequently re-claimed the mod_id (i.e., no
current mod_parsed row for (evicted_wsid, mod_id)).
Reports the *current* owner (looked up from mod_parsed) rather than the
historical evicting_wsid; multiple evictions can chain (Y→Z→W), and the
user cares about who's loaded right now. Two flavors:
- amber: current owner is also in input → mod is loaded, just not from
the wsid the user might have expected.
- red: current owner is NOT in input → mod is silently missing.
"""
if not input_wsids:
return []
rows = await conn.fetch(
"""
SELECT DISTINCT ON (mc.mod_id, mc.evicted_wsid)
mc.mod_id,
mc.evicted_wsid,
mc.evicting_wsid,
(SELECT mp.workshop_id
FROM mod_parsed mp
WHERE mp.mod_id = mc.mod_id
LIMIT 1) AS current_owner
FROM mod_id_conflicts mc
WHERE mc.evicted_wsid = ANY($1::text[])
AND NOT EXISTS (
SELECT 1 FROM mod_parsed mp
WHERE mp.workshop_id = mc.evicted_wsid
AND mp.mod_id = mc.mod_id
)
ORDER BY mc.mod_id, mc.evicted_wsid, mc.recorded_at DESC
""",
list(input_wsids),
)
if not rows:
return []
input_set = set(input_wsids)
out: List[Dict[str, Any]] = []
for r in rows:
mod_id = r["mod_id"]
evicted = r["evicted_wsid"]
# Prefer the live cache owner; fall back to the historically-evicting
# wsid if the mod_id has since been fully de-cached (e.g., manual
# force-repull recipe). Either way the user's wsid lost its claim.
owner = r["current_owner"] or r["evicting_wsid"]
if owner in input_set:
out.append({
"tag": "duplicate-mod_id",
"level": "amber",
"msg": (
f"Mod '{mod_id}' is published by both wsid {evicted} and "
f"wsid {owner}; loaded copy is from {owner}."
),
"wsid": evicted,
"mod_id": mod_id,
"evicting_wsid": owner,
})
else:
out.append({
"tag": "duplicate-mod_id",
"level": "red",
"msg": (
f"Mod '{mod_id}' is missing: wsid {evicted} declares it, "
f"but the cache holds wsid {owner}'s copy. "
f"Add {owner} to your input to load '{mod_id}'."
),
"wsid": evicted,
"mod_id": mod_id,
"evicting_wsid": owner,
})
return out
async def _build_result_for_job(
conn,
wsids: List[str],
rules_raw: Optional[str],
pz_build: Optional[str] = None,
) -> Dict[str, Any]:
"""Compute the SORTOF_DATA payload from currently-cached mod_parsed rows
for the given wsids. Used both for partial results during draining and
for the final result on phase transition to 'done'."""
if not wsids:
return _empty_payload([], "success")
rows = await conn.fetch(
"""
SELECT mp.workshop_id, mp.mod_id, mp.name, mp.category,
mp.requirements, mp.load_after, mp.load_before,
mp.incompatible_mods, mp.load_first, mp.load_last,
mp.tags, mp.maps, mp.is_addon, wm.tags AS workshop_tags
FROM mod_parsed mp
JOIN workshop_meta wm ON wm.workshop_id = mp.workshop_id
WHERE mp.workshop_id = ANY($1::text[])
AND mp.parsed_at_time_updated = wm.time_updated
ORDER BY mp.workshop_id, mp.mod_id
""",
wsids,
)
mods = [_row_to_modinfo(r) for r in rows]
rules: Dict[str, Any] = {}
if rules_raw:
try:
rules = parse_sorting_rules(rules_raw)
except Exception:
log.warning("job result: failed to parse sorting_rules")
_inject_addon_loadafter(mods)
sort_result = sort_mods(mods, rules)
cached_ids = {r["workshop_id"] for r in rows}
wsid_lookup = await _lookup_wsids_for_missing(
conn, sort_result.get("warnings", {}) or {},
pz_build=pz_build,
)
conflict_warnings = await _lookup_mod_id_conflicts(conn, wsids)
payload = adapters.build_response(
input_ids=wsids,
hit_ids=list(cached_ids),
mods=mods,
sort_result=sort_result,
status="success" if len(cached_ids) >= len(wsids) else "partial",
wsid_lookup=wsid_lookup,
pz_build=pz_build or "B42",
)
if conflict_warnings:
payload["WARNINGS"] = list(payload.get("WARNINGS") or []) + conflict_warnings
await _augment_with_required_items(conn, payload, wsids)
if pz_build:
await _emit_build_mismatch_warnings(conn, payload, wsids, pz_build)
_reorder_warnings(payload)
# Spec A §8 ownership: WORKSHOP_ITEMS_LINE preserves the SET of input
# wsids regardless of which are cached/non-mod/unknown. Within that set,
# order them by the load position of their first mod (so toggling
# branches that change wsid load position visibly reorders the line).
# Wsids without any mod in SORTED_ORDER (yet-to-cache, non_mod, unknown)
# tail at the end in their original input order.
payload["WORKSHOP_ITEMS_LINE"] = _ordered_wsids_line(payload, wsids)
# Surface terminal-failed wsids so the polling-path payload mirrors the
# sync path's unknown/non_mod arrays. Without this, jobs with broken or
# non-mod children would lack diagnostic info even after reaching `done`.
failed_rows = await conn.fetch(
"""
SELECT DISTINCT ON (workshop_id) workshop_id, status, error
FROM download_jobs
WHERE workshop_id = ANY($1::text[])
ORDER BY workshop_id, updated_at DESC
""",
wsids,
)
non_mod_set: set = set()
unknown_set: set = set()
for r in failed_rows:
if r["status"] != "failed":
continue
if r["error"] == "process_one=no_mod_info":
non_mod_set.add(r["workshop_id"])
else:
unknown_set.add(r["workshop_id"])
payload["non_mod"] = [w for w in wsids if w in non_mod_set]
payload["unknown"] = [w for w in wsids if w in unknown_set]
payload["pending"] = [
w for w in wsids
if w not in cached_ids and w not in non_mod_set and w not in unknown_set
]
return payload
def _empty_payload(input_ids: List[str], status: str) -> Dict[str, Any]:
return {
"status": status,
"MOD_DB": [],
"SORTED_ORDER": [],
"WORKSHOP_ITEMS_LINE": "",
"MODS_LINE": "",
"MAP_LINE": None,
"WARNINGS": [],
"pending": list(input_ids),
"unknown": [],
"non_mod": [],
}
@app.get("/healthz")
async def healthz() -> Dict[str, bool]:
return {"ok": True}
async def _route_to_job(
request: Request,
conn,
input_raw: str,
rules_raw: Optional[str],
bare_wsids: List[str],
collection_ids: List[str],
queue_wsids: Optional[List[str]] = None,
pz_build: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a sort_jobs row and (if needed) kick off background expansion.
Returns {status, job_id} for the client to start polling.
queue_wsids: explicit subset of wsids to insert into download_jobs.
If None, every deduped bare wsid is queued (legacy default). Callers that
have already classified hit/miss MUST pass only the misses, otherwise
already-cached wsids get redundantly redownloaded.
"""
if collection_ids:
# Will resolve in the background.
job_id = await jobs.create_job(
conn,
input_raw=input_raw,
collection_ids=collection_ids,
wsids=None,
rules_raw=rules_raw,
initial_phase="expanding",
pz_build=pz_build,
)
task = asyncio.create_task(expansion.run_expansion(
request.app.state.db,
request.app.state.http,
job_id,
bare_wsids,
collection_ids,
))
request.app.state.pending_tasks.add(task)
task.add_done_callback(request.app.state.pending_tasks.discard)
log.info(
"routed to job=%s phase=expanding bare=%d collections=%d",
job_id, len(bare_wsids), len(collection_ids),
)
return {"status": "expanding", "job_id": job_id}
else:
# Bare wsids, some uncached. Dedupe; queue only the requested subset.
seen: set = set()
wsids: List[str] = []
for w in bare_wsids:
if w not in seen:
seen.add(w)
wsids.append(w)
job_id = await jobs.create_job(
conn,
input_raw=input_raw,
collection_ids=[],
wsids=wsids,
rules_raw=rules_raw,
initial_phase="queued",
pz_build=pz_build,
)
targets = list(queue_wsids) if queue_wsids is not None else wsids
for wid in targets:
async with conn.transaction():
existing = await conn.fetchval(
"SELECT 1 FROM download_jobs "
"WHERE workshop_id = $1 AND status IN ('queued','downloading') LIMIT 1",
wid,
)
if existing is None:
await conn.execute(
"INSERT INTO download_jobs (workshop_id, status) VALUES ($1, 'queued')",
wid,
)
log.info(
"routed to job=%s phase=queued wsids=%d targets=%d",
job_id, len(wsids), len(targets),
)
return {"status": "queued", "job_id": job_id}
@app.post("/api/sort")
async def sort_endpoint(req: SortRequest, request: Request) -> Dict[str, Any]:
t0 = time.monotonic()
bare_wsids, collection_ids = parse_with_collections(req.input or "")
input_ids = bare_wsids # backwards-compat alias for the existing sync flow
if not bare_wsids and not collection_ids:
raise HTTPException(status_code=400, detail="no workshop ids found in input")
# NOTE: bound is on user-input cardinality; a collection that resolves
# to >MAX_IDS children is allowed (post-expansion cap is out of scope).
if len(bare_wsids) + len(collection_ids) > MAX_IDS:
raise HTTPException(
status_code=413,
detail=f"too many workshop ids ({len(bare_wsids) + len(collection_ids)} > {MAX_IDS})",
)
log.info(
"sort received bare=%d collections=%d",
len(bare_wsids), len(collection_ids),
)
# ── B+F: collections present → route to async job immediately, no Steam call.
# expansion.run_expansion handles GetCollectionDetails + cache + drain queueing.
if collection_ids:
pool = request.app.state.db
async with pool.acquire() as conn:
return await _route_to_job(
request, conn, req.input or "", req.rules,
bare_wsids, collection_ids,
pz_build=req.pz_build or "B42",
)
http: httpx.AsyncClient = request.app.state.http
try:
steam_details = await steam.fetch_workshop_details(http, input_ids)
except httpx.HTTPError as e:
log.warning("steam api error: %s", e)
elapsed_ms = int((time.monotonic() - t0) * 1000)
log.info(
"sort done hits=0 misses=%d status=error ms=%d",
len(input_ids), elapsed_ms,
)
return _empty_payload(input_ids, "error")
pool = request.app.state.db
hit_ids: List[str] = []
miss_ids: List[str] = []
unknown_ids: List[str] = [] # Steam returned result != 1 (deleted, typo'd, private)
non_mod_ids: List[str] = [] # Steam confirmed but DD found no mod.info (collection, art, etc.)
valid_steam: Dict[str, dict] = {}
async with pool.acquire() as conn:
for wid in input_ids:
d = steam_details.get(wid)
if not d or d.get("result") != 1:
unknown_ids.append(wid)
miss_ids.append(wid)
continue
valid_steam[wid] = d
tu = int(d.get("time_updated", 0))
row = await conn.fetchrow(
"SELECT 1 FROM mod_parsed "
"WHERE workshop_id = $1 AND parsed_at_time_updated = $2 LIMIT 1",
wid, tu,
)
if row is not None:
hit_ids.append(wid)
else:
miss_ids.append(wid)
# Detect non-mod items: Steam-valid wsids whose most-recent download_jobs
# row failed with the no_mod_info reason (drain confirmed DD pulled no
# parseable .info file). Surfaces collections, art, etc., separately
# from real "still being indexed" pending items.
valid_miss_ids = [w for w in miss_ids if w in valid_steam]
if valid_miss_ids:
non_mod_rows = await conn.fetch(
"""
SELECT DISTINCT ON (workshop_id) workshop_id, status, error
FROM download_jobs
WHERE workshop_id = ANY($1::text[])
ORDER BY workshop_id, updated_at DESC
""",
valid_miss_ids,
)
for r in non_mod_rows:
if r["status"] == "failed" and r["error"] == "process_one=no_mod_info":
non_mod_ids.append(r["workshop_id"])
# ── B+F: route to async job if any wsid needs drain time.
# The job's GET endpoint will surface unknown/non_mod via its own
# response shape; the sync path remains for fully-cached input.
# `hit_ids` only contains wsids confirmed cached; anything else
# (unknown OR uncached-but-valid) is reason to switch to a job.
if len(hit_ids) < len(bare_wsids):
# Match the sync path's queue predicate: skip Steam-unknown wsids
# AND skip wsids the drain has already classified as non-mods.
# Without this filter, every cache miss including known-bad ones
# gets queued, plus any cached wsids upstream of an unknown would
# also get re-queued (the bug we're fixing).
non_mod_set_route = set(non_mod_ids)
queue_targets = [
w for w in miss_ids
if w in valid_steam and w not in non_mod_set_route
]
return await _route_to_job(
request, conn, req.input or "", req.rules,
bare_wsids, [], # collection_ids empty - already short-circuited above
queue_wsids=queue_targets,
pz_build=req.pz_build or "B42",
)
# Queue cache misses (only those Steam confirmed exist AND aren't
# already known to be non-mods - re-queueing those is pointless).
non_mod_set = set(non_mod_ids)
for wid in miss_ids:
if wid not in valid_steam:
log.info("skip queue for unknown wsid=%s", wid)
continue
if wid in non_mod_set:
log.info("skip queue for non-mod wsid=%s", wid)
continue
async with conn.transaction():
existing = await conn.fetchval(
"SELECT 1 FROM download_jobs "
"WHERE workshop_id = $1 AND status IN ('queued','downloading') "
"LIMIT 1",
wid,
)
if existing is None:
await conn.execute(
"INSERT INTO download_jobs (workshop_id, status) "
"VALUES ($1, 'queued')",
wid,
)
if hit_ids:
rows = await conn.fetch(
"""
SELECT mp.workshop_id, mp.mod_id, mp.name, mp.category,
mp.requirements, mp.load_after, mp.load_before,
mp.incompatible_mods, mp.load_first, mp.load_last,
mp.tags, mp.maps, mp.is_addon, wm.tags AS workshop_tags
FROM mod_parsed mp
JOIN workshop_meta wm ON wm.workshop_id = mp.workshop_id
WHERE mp.workshop_id = ANY($1::text[])
AND mp.parsed_at_time_updated = wm.time_updated
ORDER BY mp.workshop_id, mp.mod_id
""",
hit_ids,
)
else:
rows = []
mods: List[ModInfo] = [_row_to_modinfo(r) for r in rows]
rules: Dict[str, Any] = {}
if req.rules:
try:
rules = parse_sorting_rules(req.rules)
except Exception:
log.warning("failed to parse sorting_rules; ignoring")
rules = {}
_inject_addon_loadafter(mods)
sort_result = sort_mods(mods, rules)
if not hit_ids:
status = "cold"
elif len(hit_ids) == len(input_ids):
status = "success"
else:
status = "partial"
async with pool.acquire() as conn:
wsid_lookup = await _lookup_wsids_for_missing(
conn, sort_result.get("warnings", {}) or {},
pz_build=req.pz_build or "B42",
)
conflict_warnings = await _lookup_mod_id_conflicts(conn, input_ids)
payload = adapters.build_response(
input_ids=input_ids,
hit_ids=hit_ids,
mods=mods,
sort_result=sort_result,
status=status,
wsid_lookup=wsid_lookup,
pz_build=req.pz_build or "B42",
)
if conflict_warnings:
payload["WARNINGS"] = list(payload.get("WARNINGS") or []) + conflict_warnings
async with pool.acquire() as conn:
await _augment_with_required_items(conn, payload, input_ids)
await _emit_build_mismatch_warnings(
conn, payload, input_ids, req.pz_build or "B42",
)
_reorder_warnings(payload)
# Surface ghost / non-mod IDs separately from real pending so the UI can
# distinguish "indexing 2 mods" from "2 of your IDs are deleted/typo'd"
# and "1 of your IDs is a collection, not a mod."
payload["unknown"] = unknown_ids
payload["non_mod"] = non_mod_ids
# Trim them out of pending so each ID lives in exactly one category.
excluded = set(unknown_ids) | set(non_mod_ids)
payload["pending"] = [w for w in payload.get("pending", []) if w not in excluded]
# Spec A §8 + Spec C §2: load-order wsids that landed in the sort, then
# tail of unknown/non_mod (preserves the user's subscription set). Without
# this the sync path's WORKSHOP_ITEMS_LINE would silently drop them.
payload["WORKSHOP_ITEMS_LINE"] = _ordered_wsids_line(payload, input_ids)
elapsed_ms = int((time.monotonic() - t0) * 1000)
log.info(
"sort done hits=%d misses=%d unknown=%d non_mod=%d status=%s ms=%d",
len(hit_ids), len(miss_ids), len(unknown_ids), len(non_mod_ids), status, elapsed_ms,
)
return payload
def _merge_warnings(seeded: Optional[Dict[str, Any]], fresh: Dict[str, Any]) -> Dict[str, Any]:
"""Merge any pre-seeded WARNINGS (e.g., 'collection-partial' from expansion)
into a freshly-computed result payload. Seeded warnings come first to
preserve "this happened earlier" ordering. Dedupes by (tag, msg)."""
seeded_warnings = (seeded or {}).get("WARNINGS") or []
fresh_warnings = fresh.get("WARNINGS") or []
if not seeded_warnings:
return fresh
seen = set()
merged: List[Dict[str, Any]] = []
for w in list(seeded_warnings) + list(fresh_warnings):
key = (w.get("tag", ""), w.get("msg", ""))
if key in seen:
continue
seen.add(key)
merged.append(w)
fresh["WARNINGS"] = merged
return fresh
@app.get("/api/jobs/{job_id}")
async def get_job_endpoint(job_id: str, request: Request) -> Dict[str, Any]:
pool = request.app.state.db
async with pool.acquire() as conn:
row = await jobs.get_job_row(conn, job_id)
if row is None:
raise HTTPException(status_code=404, detail="job not found or expired")
wsids = list(row["wsids"]) if row["wsids"] else None
counts = await jobs.compute_counts(conn, wsids or [])
phase = jobs.derive_phase(row["phase"], wsids, counts)
result_json = row["result_json"]
job_pz_build = row.get("pz_build")
if phase == "done" and row["phase"] != "done":
# Job just completed. Compute fresh and persist, but preserve
# any expansion-seeded warnings (e.g., 'collection-partial').
fresh = await _build_result_for_job(
conn, wsids, row["rules_raw"], pz_build=job_pz_build,
)
result_json = _merge_warnings(row["result_json"], fresh)
await jobs.update_phase(
conn, job_id, "done", result_json=result_json,
)
elif phase != "done" and wsids:
# Mid-flight partial: compute fresh on every poll, also merging
# in any seeded warnings so the client sees them throughout.
# Don't persist; only `done` transitions write result_json.
fresh = await _build_result_for_job(
conn, wsids, row["rules_raw"], pz_build=job_pz_build,
)
result_json = _merge_warnings(row["result_json"], fresh)
# `counts` is {cached, queued, draining, terminal_failed} - additive
# extension of the Spec B+F §6 contract. JS clients ignore the extra key.
return {
"job_id": str(row["job_id"]),
"phase": phase,
"counts": counts,
"wsids": wsids,
"result": result_json,
"failure_reason": row["failure_reason"],
}
@app.delete("/api/jobs/{job_id}", status_code=204)
async def delete_job_endpoint(job_id: str, request: Request):
"""Cancel a job. Idempotent: cancelling a terminal job is a no-op 204.
Does NOT touch download_jobs (Spec B+F §8)."""
try:
uid = UUID(job_id)
except ValueError:
raise HTTPException(status_code=404, detail="job not found")
pool = request.app.state.db
async with pool.acquire() as conn:
# Single SQL: race-safe - a no-op when the row is missing OR terminal.
result = await conn.execute(
"""
UPDATE sort_jobs
SET phase = 'failed',
failure_reason = 'cancelled',
phase_started_at = now()
WHERE job_id = $1
AND phase NOT IN ('done', 'failed')
""",
uid,
)
# If 0 rows updated, either the row didn't exist OR was already terminal.
# Disambiguate so we still return 404 for nonexistent jobs.
if result == "UPDATE 0":
exists = await conn.fetchval(
"SELECT 1 FROM sort_jobs WHERE job_id = $1", uid,
)
if exists is None:
raise HTTPException(status_code=404, detail="job not found")
log.info("delete_job %s -> failed/cancelled (or no-op terminal)", job_id)
return None
@app.post("/api/resort")
async def resort_endpoint(req: ResortRequest, request: Request) -> Dict[str, Any]:
"""Re-run mlos_sort against a user-supplied subset of mod_ids.
Stateless. No Steam round-trip. Filters mod_parsed by the requested
mod_ids, runs sort_mods, returns the same shape as /api/sort.
"""
t0 = time.monotonic()
ids = list(req.selected_mod_ids or [])
if not ids:
raise HTTPException(status_code=400, detail="selected_mod_ids must be non-empty")
if len(ids) > MAX_IDS:
raise HTTPException(status_code=413, detail=f"too many mod_ids ({len(ids)} > {MAX_IDS})")
for i, mod_id in enumerate(ids):
if not isinstance(mod_id, str) or not (1 <= len(mod_id) <= 256):
raise HTTPException(
status_code=400,
detail=f"invalid mod_id at index {i}: must be a string of length 1-256",
)
pool = request.app.state.db
async with pool.acquire() as conn:
# Fetch ALL mod_ids of any wsid that has at least one selected mod_id.
# _apply_branch_rules needs the full group context to keep emitting
# auto-picked-branch warnings (the picker UI is data-driven from
# those warnings). Without the siblings, narrowing a multi-branch
# wsid to one branch would silently hide the picker.
rows = await conn.fetch(
"""
WITH selected_wsids AS (
SELECT DISTINCT mp.workshop_id
FROM mod_parsed mp
JOIN workshop_meta wm ON wm.workshop_id = mp.workshop_id
WHERE mp.mod_id = ANY($1::text[])
AND mp.parsed_at_time_updated = wm.time_updated
)
SELECT mp.workshop_id, mp.mod_id, mp.name, mp.category,
mp.requirements, mp.load_after, mp.load_before,
mp.incompatible_mods, mp.load_first, mp.load_last,
mp.tags, mp.maps, mp.is_addon, wm.tags AS workshop_tags
FROM mod_parsed mp
JOIN workshop_meta wm ON wm.workshop_id = mp.workshop_id
WHERE mp.workshop_id IN (SELECT workshop_id FROM selected_wsids)
AND mp.parsed_at_time_updated = wm.time_updated
ORDER BY mp.workshop_id, mp.mod_id
""",
ids,
)
selected_set = set(ids)
found_selected = {r["mod_id"] for r in rows if r["mod_id"] in selected_set}
missing = [mid for mid in ids if mid not in found_selected]
if missing:
log.info("resort dropped unknown mod_ids count=%d sample=%s",
len(missing), missing[:3])
if not rows:
raise HTTPException(status_code=400, detail="no known mod_ids in selection")
all_mods: List[ModInfo] = [_row_to_modinfo(r) for r in rows]
selected_mods: List[ModInfo] = [m for m in all_mods if m.id in selected_set]
_inject_addon_loadafter(selected_mods)
# Sort over the user's explicit selection so SORTED_ORDER reflects only
# what they ticked. _apply_branch_rules below still sees the full group
# via `mods=all_mods`, so it can emit picker-driving warnings.
sort_result = sort_mods(selected_mods, {})
# Per spec review #12: silently drop unknown mod_ids and return status="success".
# The frontend reconciles its sent list against MOD_DB to detect drops.
async with pool.acquire() as conn:
wsid_lookup = await _lookup_wsids_for_missing(
conn, sort_result.get("warnings", {}) or {},
pz_build=req.pz_build or "B42",
)
payload = adapters.build_response(
input_ids=[],
hit_ids=ids,
mods=all_mods,
sort_result=sort_result,
status="success",
wsid_lookup=wsid_lookup,
pz_build=req.pz_build or "B42",
is_resort=True,
selected_modids=selected_set,
)
# Resort operates on a known mod_id set - there's no concept of a
# "still-being-fetched" wsid here. With input_ids=[] above, build_response
# already produces pending=[]; this is explicit defense against future
# build_response changes.
payload["pending"] = []
# The frontend owns unknown/non_mod from the original /api/sort response
# for the lifetime of the result set (same ownership contract as
# WORKSHOP_ITEMS_LINE, see sortContextRef). Resort returns empty arrays;
# the client overrides them.
payload["unknown"] = []
payload["non_mod"] = []
# Emit the same secondary warnings /api/sort and the polling path do.
# Without these, every resort (e.g., branch toggle) wipes build-mismatch
# / duplicate-mod_id / required-item augmentations from the warnings
# panel — the user-visible "warnings disappear" bug.
# Prefer the original sort's input wsids (the user's subscription set)
# for wsid-keyed warnings. Mod_id-derived wsids omit B41 wsids whose
# mod_id has since been evicted to a B42 sibling, dropping legitimate
# build-mismatch warnings the user expects to keep seeing across resort.
derived_wsids = {m.workshop_id for m in selected_mods if m.workshop_id}
resort_wsids = list({*(req.input_wsids or []), *derived_wsids})
if resort_wsids:
async with pool.acquire() as conn:
conflict_warnings = await _lookup_mod_id_conflicts(conn, resort_wsids)
await _augment_with_required_items(conn, payload, resort_wsids)
if req.pz_build:
await _emit_build_mismatch_warnings(
conn, payload, resort_wsids, req.pz_build,
)
if conflict_warnings:
payload["WARNINGS"] = list(payload.get("WARNINGS") or []) + conflict_warnings
_reorder_warnings(payload)
elapsed_ms = int((time.monotonic() - t0) * 1000)
log.info(
"resort done count=%d dropped=%d ms=%d",
len(rows), len(missing), elapsed_ms,
)
return payload
# ── Broken-mod report board ────────────────────────────────────────────────
class BrokenModReportRequest(BaseModel):
workshop_id: str = Field(..., min_length=1, max_length=32)
version: str = Field(..., min_length=1, max_length=32)
class BrokenModVoteRequest(BaseModel):
direction: str = Field(..., pattern=r"^(up|down)$")
@app.get("/api/pz-versions")
async def pz_versions_endpoint(request: Request) -> Dict[str, str]:
"""Branch-key → human-readable build label. Source: data/pz_versions.json
loaded at lifespan startup. Frontend dropdown reads from here."""
return dict(request.app.state.pz_versions or {})
@app.get("/api/broken-mods")
async def list_broken_mods(
request: Request,
q: Optional[str] = None,
limit: int = 100,
) -> List[Dict[str, Any]]:
"""List reports newest-first. `q` filters by wsid substring."""
limit = max(1, min(int(limit or 100), 500))
pool = request.app.state.db
async with pool.acquire() as conn:
if q:
rows = await conn.fetch(
"""
SELECT id, workshop_id, mod_name, version,
upvotes, downvotes,
created_at, updated_at
FROM broken_mod_reports
WHERE workshop_id LIKE '%' || $1 || '%'
ORDER BY updated_at DESC
LIMIT $2
""",
q.strip(), limit,
)
else:
rows = await conn.fetch(
"""
SELECT id, workshop_id, mod_name, version,
upvotes, downvotes,
created_at, updated_at
FROM broken_mod_reports
ORDER BY updated_at DESC
LIMIT $1
""",
limit,
)
return [
{
"id": int(r["id"]),
"workshop_id": r["workshop_id"],
"mod_name": r["mod_name"] or "",
"version": r["version"],
"upvotes": int(r["upvotes"]),
"downvotes": int(r["downvotes"]),
"created_at": r["created_at"].isoformat(),
"updated_at": r["updated_at"].isoformat(),
}
for r in rows
]
@app.post("/api/broken-mods")
async def create_broken_mod_report(
req: BrokenModReportRequest,
request: Request,
) -> Dict[str, Any]:
"""Create or refresh a broken-mod report. Re-submitting the same
(workshop_id, version) bumps `updated_at` and preserves vote counts.
Resolves `mod_name` from the existing workshop_meta cache; if missing
we fetch from Steam (anonymous GetPublishedFileDetails) so the user
gets a meaningful label without having to type it.
"""
if not req.workshop_id.isdigit():
raise HTTPException(
status_code=400,
detail="workshop_id must be a numeric Steam Workshop ID",
)
valid_versions = set(request.app.state.pz_versions or {}) or {
"stable", "unstable", "outdatedunstable",
}
if req.version not in valid_versions:
raise HTTPException(
status_code=400,
detail=f"version must be one of: {sorted(valid_versions)}",
)
pool = request.app.state.db
mod_name: Optional[str] = None
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT title FROM workshop_meta WHERE workshop_id = $1",
req.workshop_id,
)
if row and row["title"]:
mod_name = row["title"]
if not mod_name:
# Cache miss → ask Steam directly. Treat any failure as "unknown
# name" rather than hard-erroring; the user still gets a row.
try:
details = await steam.fetch_workshop_details(
request.app.state.http, [req.workshop_id],
)
d = details.get(req.workshop_id) or {}
if d.get("result") == 1:
mod_name = d.get("title") or None
except Exception as e:
log.info("broken-mod: Steam lookup for %s failed: %s",
req.workshop_id, e)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO broken_mod_reports
(workshop_id, mod_name, version)
VALUES ($1, $2, $3)
ON CONFLICT (workshop_id, version) DO UPDATE
SET updated_at = now(),
mod_name = COALESCE(EXCLUDED.mod_name, broken_mod_reports.mod_name)
RETURNING id, workshop_id, mod_name, version,
upvotes, downvotes, created_at, updated_at
""",
req.workshop_id, mod_name, req.version,
)
return {
"id": int(row["id"]),
"workshop_id": row["workshop_id"],
"mod_name": row["mod_name"] or "",
"version": row["version"],
"upvotes": int(row["upvotes"]),
"downvotes": int(row["downvotes"]),
"created_at": row["created_at"].isoformat(),
"updated_at": row["updated_at"].isoformat(),
}
@app.post("/api/broken-mods/{report_id}/vote")
async def vote_broken_mod(
report_id: int,
req: BrokenModVoteRequest,
request: Request,
) -> Dict[str, int]:
"""Increment upvotes or downvotes on an existing report."""
column = "upvotes" if req.direction == "up" else "downvotes"
pool = request.app.state.db
async with pool.acquire() as conn:
row = await conn.fetchrow(
f"""
UPDATE broken_mod_reports
SET {column} = {column} + 1
WHERE id = $1
RETURNING upvotes, downvotes
""",
report_id,
)
if row is None:
raise HTTPException(status_code=404, detail="report not found")
return {"upvotes": int(row["upvotes"]), "downvotes": int(row["downvotes"])}
# ── static frontend ────────────────────────────────────────────────────────
# Mount LAST so all API routes win path resolution.
_FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
if _FRONTEND_DIR.is_dir():
app.mount(
"/",
StaticFiles(directory=str(_FRONTEND_DIR), html=True),
name="frontend",
)