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:
119
api/app.py
119
api/app.py
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user