feat: stale-require filter + Steam-API-keyed required-items fetch

Drops missing-dep warnings whose source mod's mod.info `require=` is
out of sync with its Steam Workshop Required Items sidebar. Author
edits to mod.info often lag build ports; trusting the sidebar means
B42 sorts no longer raise warnings on B41-only deps the author has
already retired (e.g. tikitown's Diederiks Tile Palooza, EN_Newburbs).

Filter is conservative: only drops a dep when (a) we have a cached
wsid for it, (b) that wsid is wrong-build for the user's pz_build,
and (c) the source mod's required_wsids list (with required_scraped_at
populated as the "we have evidence" gate, since the column itself
defaults to '{}') excludes that wsid.

Also swaps worker.fetch_required_wsids from public-page HTML scrape
to authenticated IPublishedFileService/GetDetails. Same `children`
data, no 429 cooldowns. Removes the now-unused throttle/cooldown
infrastructure (SORTOF_STEAM_MIN_INTERVAL / SORTOF_STEAM_COOLDOWN
env vars are no longer read).

See docs/specs/2026-05-06-stale-requires-filter.md.
This commit is contained in:
2026-05-06 21:30:28 +00:00
parent f8b48fbacb
commit 3a34b71e54
3 changed files with 286 additions and 116 deletions

View File

@@ -315,6 +315,110 @@ async def _lookup_wsids_for_missing(
return out
async def _filter_stale_requires(
conn,
mlos_warnings: Dict[str, Any],
source_wsids: Dict[str, str],
pz_build: Optional[str],
) -> None:
"""Drop missing-dep entries that look stale: the dep is wrong-build for
`pz_build` AND the source mod's Steam-side Required Items sidebar does
NOT list the dep's wsid. Authors update Required Items per build but
routinely forget to clean up `mod.info`'s `require=` line, so a
wrong-build mod.info dep that the author has implicitly removed from
Required Items is treated as stale and silently dropped.
Mutates `mlos_warnings["missing_requirements"]` in place. Does nothing
when pz_build is unknown, when no source has Required Items data
cached, or when the dep can't be resolved to a wsid (we have no
evidence to drop on, so keep the warning).
Example: tikitown's mod.info still says
`require=Diederiks Tile Palooza,EN_Newburbs,...` from B41, but its
B42 Required Items list contains only Drazion's + Erika's. On a B42
sort the two B41-only deps are dropped (they're not in the sidebar),
while the rest of the require= line continues to resolve normally.
"""
if not pz_build:
return
missing_reqs = mlos_warnings.get("missing_requirements") or {}
if not missing_reqs:
return
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
)
if not target_tag or not other_tag:
return
src_wsids_to_check = list({
source_wsids[m] for m in missing_reqs if m in source_wsids
})
if not src_wsids_to_check:
return
# `required_wsids` has `NOT NULL DEFAULT '{}'`, so `IS NOT NULL` is
# always true and useless as a "we have evidence" gate. The real
# sentinel is `required_scraped_at` — NULL by default, set only when
# the worker successfully fetched Required Items for this wsid.
# Without this, every never-scraped source mod (most of the cache
# at time of writing) would be treated as "author lists no required
# items", and we'd silently suppress legit wrong-build warnings.
src_required_rows = await conn.fetch(
"""
SELECT workshop_id, required_wsids
FROM workshop_meta
WHERE workshop_id = ANY($1::text[])
AND required_scraped_at IS NOT NULL
""",
src_wsids_to_check,
)
if not src_required_rows:
return
src_required: Dict[str, set] = {
r["workshop_id"]: set(r["required_wsids"] or [])
for r in src_required_rows
}
all_deps = {d for deps in missing_reqs.values() for d in deps if d}
if not all_deps:
return
dep_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(all_deps),
)
dep_info: Dict[str, Tuple[str, List[str]]] = {
r["mod_id"]: (r["workshop_id"], list(r["tags"] or []))
for r in dep_rows
}
new_missing: Dict[str, List[str]] = {}
for src_mod_id, deps in missing_reqs.items():
src_wsid = source_wsids.get(src_mod_id)
required = src_required.get(src_wsid) if src_wsid else None
kept: List[str] = []
for dep in deps:
info = dep_info.get(dep)
if info and required is not None:
dep_wsid, dep_tags = info
wrong_build = (
other_tag in dep_tags and target_tag not in dep_tags
)
if wrong_build and dep_wsid not in required:
continue # stale: author dropped from Required Items
kept.append(dep)
if kept:
new_missing[src_mod_id] = kept
mlos_warnings["missing_requirements"] = new_missing
# 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
@@ -869,6 +973,11 @@ async def _build_result_for_job(
_inject_addon_loadafter(mods)
sort_result = sort_mods(mods, rules)
cached_ids = {r["workshop_id"] for r in rows}
await _filter_stale_requires(
conn, sort_result.get("warnings", {}) or {},
{m.id: m.workshop_id for m in mods if m.workshop_id},
pz_build,
)
wsid_lookup = await _lookup_wsids_for_missing(
conn, sort_result.get("warnings", {}) or {},
pz_build=pz_build,
@@ -1195,6 +1304,11 @@ async def sort_endpoint(req: SortRequest, request: Request) -> Dict[str, Any]:
status = "partial"
async with pool.acquire() as conn:
await _filter_stale_requires(
conn, sort_result.get("warnings", {}) or {},
{m.id: m.workshop_id for m in mods if m.workshop_id},
req.pz_build or "B42",
)
wsid_lookup = await _lookup_wsids_for_missing(
conn, sort_result.get("warnings", {}) or {},
pz_build=req.pz_build or "B42",
@@ -1414,6 +1528,11 @@ async def resort_endpoint(req: ResortRequest, request: Request) -> Dict[str, Any
# 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:
await _filter_stale_requires(
conn, sort_result.get("warnings", {}) or {},
{m.id: m.workshop_id for m in selected_mods if m.workshop_id},
req.pz_build or "B42",
)
wsid_lookup = await _lookup_wsids_for_missing(
conn, sort_result.get("warnings", {}) or {},
pz_build=req.pz_build or "B42",