- data/modpack_rules/helldrinx.txt: bundled rules for HellDrinx FULL/LITE - app.py auto-injects modpack rules when a trigger wsid is in input; user-supplied rules are appended after and override on conflict - MANUAL_BUILD_PAIRS: betterLockpicking (B41) ↔ NFsBetterLockpicking (B42) - mlos_sort.py: minor adjustments (kept in lockstep across api/worker)
1656 lines
65 KiB
Python
1656 lines
65 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, Tuple
|
|
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 diagnostics
|
|
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 [],
|
|
mod_types=list(r["mod_types"] or []) if "mod_types" 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",
|
|
# betterLockpicking (B41) ↔ NFsBetterLockpicking (B42). Different author
|
|
# picked up the B42 port and renamed the mod_id, so the mod_parsed-based
|
|
# cross-reference can't find the pair.
|
|
"2368058459": "3440867775",
|
|
"3440867775": "2368058459",
|
|
}
|
|
|
|
|
|
# Modpack-bundled sorting_rules.txt files: when a trigger wsid is in the
|
|
# user's input, we prepend the matching file's contents to the user's rules
|
|
# text before parse_sorting_rules runs. User-supplied rules come last so they
|
|
# override modpack defaults on conflicting keys.
|
|
_MODPACK_RULES_DIR = Path(__file__).resolve().parent.parent / "data" / "modpack_rules"
|
|
_MODPACK_RULES_TRIGGERS: Dict[str, str] = {
|
|
# HellDrinx FULL + LITE both bundle the same sorting_rules.txt.
|
|
"3672556207": "helldrinx.txt", # HellDrinx FULL
|
|
"3662909244": "helldrinx.txt", # HellDrinx LITE
|
|
}
|
|
# Human-readable trigger labels for the warning message.
|
|
_MODPACK_RULES_LABELS: Dict[str, str] = {
|
|
"3672556207": "HellDrinx FULL",
|
|
"3662909244": "HellDrinx LITE",
|
|
}
|
|
|
|
|
|
def _modpack_rules_for(input_wsids: List[str]) -> Tuple[str, List[Tuple[str, str]]]:
|
|
"""Return (rules_text, triggers) where triggers is a list of (wsid, label)
|
|
tuples that fired and rules_text is the concatenated content of every
|
|
distinct file referenced. Each rules file is included at most once even
|
|
if multiple trigger wsids share it (HellDrinx FULL + LITE case)."""
|
|
files_seen: set = set()
|
|
parts: List[str] = []
|
|
triggers: List[Tuple[str, str]] = []
|
|
for wsid in input_wsids:
|
|
fname = _MODPACK_RULES_TRIGGERS.get(wsid)
|
|
if not fname:
|
|
continue
|
|
triggers.append((wsid, _MODPACK_RULES_LABELS.get(wsid, wsid)))
|
|
if fname in files_seen:
|
|
continue
|
|
files_seen.add(fname)
|
|
try:
|
|
parts.append((_MODPACK_RULES_DIR / fname).read_text(encoding="utf-8"))
|
|
except OSError as e:
|
|
log.warning("modpack rules load failed for %s (%s): %s", wsid, fname, e)
|
|
return ("\n\n".join(parts), triggers)
|
|
|
|
|
|
def _parse_rules_with_modpacks(
|
|
rules_raw: Optional[str],
|
|
input_wsids: List[str],
|
|
) -> Tuple[Dict[str, Any], List[Tuple[str, str]]]:
|
|
"""Parse user-supplied rules with any auto-detected modpack rules
|
|
prepended. Returns (parsed_rules, triggers). Caller emits a warning per
|
|
trigger so the user knows the auto-injection happened."""
|
|
modpack_text, triggers = _modpack_rules_for(input_wsids)
|
|
combined = (
|
|
(modpack_text + "\n\n" + (rules_raw or "")).strip()
|
|
if modpack_text else (rules_raw or "")
|
|
)
|
|
if not combined:
|
|
return ({}, triggers)
|
|
try:
|
|
return (parse_sorting_rules(combined), triggers)
|
|
except Exception:
|
|
log.warning("failed to parse modpack+user rules; ignoring")
|
|
return ({}, triggers)
|
|
|
|
|
|
def _emit_modpack_rules_warnings(
|
|
payload: Dict[str, Any],
|
|
triggers: List[Tuple[str, str]],
|
|
) -> None:
|
|
"""Append a `modpack-rules-applied` warning per modpack trigger so users
|
|
know the modpack's bundled sorting_rules.txt was auto-injected. Skips
|
|
triggers already flagged (idempotent across resort cycles)."""
|
|
if not triggers:
|
|
return
|
|
existing = payload.get("WARNINGS") or []
|
|
already_flagged = {
|
|
w.get("wsid") for w in existing
|
|
if w.get("tag") == "modpack-rules-applied" and w.get("wsid")
|
|
}
|
|
new_warnings: List[Dict[str, Any]] = []
|
|
for wsid, label in triggers:
|
|
if wsid in already_flagged:
|
|
continue
|
|
new_warnings.append({
|
|
"tag": "modpack-rules-applied",
|
|
"level": "amber",
|
|
"wsid": wsid,
|
|
"msg": (
|
|
f"{label} ({wsid}) detected — auto-applied its bundled "
|
|
f"sorting_rules.txt. Your manually-entered rules (if any) "
|
|
f"override modpack rules."
|
|
),
|
|
})
|
|
if new_warnings:
|
|
payload["WARNINGS"] = list(existing) + new_warnings
|
|
|
|
|
|
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. Cases per wsid:
|
|
- both builds tagged → multi-build, no warn
|
|
- only the OPPOSITE build tagged → wrong build, emit warn
|
|
- neither build tagged BUT a MANUAL_BUILD_PAIRS partner is tagged for
|
|
the target build → infer wrong build, emit warn (covers untagged
|
|
wsids whose author never set a Steam build tag)
|
|
- neither build tagged AND no paired partner → 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
|
|
# Expand the lookup set to include MANUAL_BUILD_PAIRS partners so we can
|
|
# check the partner's Steam tags in the same query (no N+1).
|
|
lookup_wsids = set(input_wsids)
|
|
for w in input_wsids:
|
|
if w in MANUAL_BUILD_PAIRS:
|
|
lookup_wsids.add(MANUAL_BUILD_PAIRS[w])
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT workshop_id, title, tags
|
|
FROM workshop_meta
|
|
WHERE workshop_id = ANY($1::text[])
|
|
""",
|
|
list(lookup_wsids),
|
|
)
|
|
tags_by_wsid: Dict[str, List[str]] = {r["workshop_id"]: list(r["tags"] or []) for r in rows}
|
|
titles_by_wsid: Dict[str, str] = {r["workshop_id"]: r["title"] for r in rows}
|
|
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 wsid in input_wsids:
|
|
if wsid in already_flagged:
|
|
continue
|
|
tags = tags_by_wsid.get(wsid)
|
|
if tags is None:
|
|
continue # no workshop_meta row cached
|
|
if target_tag in tags:
|
|
continue # supports the picked build
|
|
# Two paths to "this is the wrong build":
|
|
# (a) Steam directly tags it as the OPPOSITE build.
|
|
# (b) MANUAL_BUILD_PAIRS partner is tagged for our target build.
|
|
partner_supports_target = (
|
|
wsid in MANUAL_BUILD_PAIRS
|
|
and target_tag in tags_by_wsid.get(MANUAL_BUILD_PAIRS[wsid], [])
|
|
)
|
|
if other_tag not in tags and not partner_supports_target:
|
|
continue # untagged with no helpful partner — can't tell
|
|
title = titles_by_wsid.get(wsid) 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, mp.mod_types, 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, modpack_triggers = _parse_rules_with_modpacks(rules_raw, wsids)
|
|
_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)
|
|
_emit_modpack_rules_warnings(payload, modpack_triggers)
|
|
_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, mp.mod_types, 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, modpack_triggers = _parse_rules_with_modpacks(req.rules, input_ids)
|
|
|
|
_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",
|
|
)
|
|
_emit_modpack_rules_warnings(payload, modpack_triggers)
|
|
_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, mp.mod_types, 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"])}
|
|
|
|
|
|
@app.post("/api/conflicts")
|
|
async def conflicts_endpoint(req: SortRequest, request: Request) -> Dict[str, Any]:
|
|
"""Detect rel_paths claimed by ≥2 input mods with non-equal sha1.
|
|
|
|
v1: bare wsids only. Collection input returns 400 so the caller can
|
|
resolve via /api/sort first (where the async-job + drain-progress
|
|
plumbing already lives). Mods whose `files_manifest_built` is false
|
|
cannot be analyzed and are reported in `missing_manifests` instead of
|
|
silently ignored.
|
|
"""
|
|
bare_wsids, collection_ids = parse_with_collections(req.input or "")
|
|
if collection_ids:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="conflict scan does not support collection input; resolve via /api/sort first",
|
|
)
|
|
if not bare_wsids:
|
|
raise HTTPException(status_code=400, detail="no workshop ids found in input")
|
|
if len(bare_wsids) > MAX_IDS:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"too many workshop ids ({len(bare_wsids)} > {MAX_IDS})",
|
|
)
|
|
|
|
pool = request.app.state.db
|
|
async with pool.acquire() as conn:
|
|
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, mp.mod_types,
|
|
mp.files_manifest_built, 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
|
|
""",
|
|
bare_wsids,
|
|
)
|
|
|
|
mods: List[ModInfo] = [_row_to_modinfo(r) for r in rows]
|
|
|
|
# Missing-manifest wsids: input wsids that have no mod_parsed rows
|
|
# OR whose rows all have files_manifest_built=false. Any single
|
|
# built row in a multi-mod wsid counts as "manifest available".
|
|
wsid_has_manifest: Dict[str, bool] = {}
|
|
for r in rows:
|
|
w = r["workshop_id"]
|
|
built = bool(r["files_manifest_built"])
|
|
wsid_has_manifest[w] = wsid_has_manifest.get(w, False) or built
|
|
missing_manifests = [w for w in bare_wsids if not wsid_has_manifest.get(w, False)]
|
|
|
|
conflicts = await diagnostics.scan_file_conflicts(conn, mods)
|
|
|
|
return {
|
|
"conflicts": [
|
|
{"rel_path": c.rel_path, "providers": c.providers, "winner": c.winner}
|
|
for c in conflicts
|
|
],
|
|
"missing_manifests": missing_manifests,
|
|
}
|
|
|
|
|
|
# ── 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",
|
|
)
|