diff --git a/app/intake_routes.py b/app/intake_routes.py index fd28b56..97555f6 100644 --- a/app/intake_routes.py +++ b/app/intake_routes.py @@ -71,6 +71,12 @@ async def _enrich(db, rid): hit = await _cache_hit(db, rid) if hit: return dict(hit) + return await _fetch_release(db, rid) + + +async def _fetch_release(db, rid): + """ALWAYS hit the Discogs API for this release and grow the mirror (release+artist+label+thumb), + ignoring any existing cache row. Used by Heal to backfill missing cover art / metadata.""" try: c, ok = await _client(db) async with c: @@ -514,6 +520,82 @@ async def gsheet_import(ident=Depends(require_token), db=Depends(get_db)): return {"ok": True, "staged": staged, "skipped": skipped} +# --- Heal: backfill missing catalog metadata (the salvaged self-healing pass) ---- +# WowPlatter healed gaps inline during a 3-hour import; on metal it's a bounded one-button sweep. +# Phase 1 = LOCAL backfill from the mirror (free, instant). Phase 2 = bounded Discogs re-fetch for +# release_ids missing from the mirror or without cover art (rate-limited → re-run for the rest). + +# in-stock rows in this store whose release_id is missing from the mirror, or whose cached cover is blank +_NEEDS_API = """release_id IS NOT NULL AND ( + NOT EXISTS (SELECT 1 FROM disc_cache dc WHERE dc.release_id=i.release_id) + OR EXISTS (SELECT 1 FROM disc_cache dc WHERE dc.release_id=i.release_id + AND (dc.thumb IS NULL OR dc.thumb='')))""" + + +@router.get("/heal/scan") +async def heal_scan(ident=Depends(require_token), db=Depends(get_db)): + """Dry-run: count the gaps before healing anything.""" + sid = {"sid": ident["store_id"]} + base = "FROM inventory i WHERE i.store_id=:sid AND i.in_stock" + + async def n(extra): + return (await db.execute(text(f"SELECT count(*) {base} AND {extra}"), sid)).scalar() + + async def nd(extra): + return (await db.execute(text(f"SELECT count(DISTINCT i.release_id) {base} AND {extra}"), sid)).scalar() + + return { + "ok": True, + "no_release_id": await n("i.release_id IS NULL"), # can't auto-heal (needs matching) + "missing_title": await n("i.release_id IS NOT NULL AND i.title IS NULL"), + "missing_weight": await n("i.release_id IS NOT NULL AND i.weight_g IS NULL"), + "local_fixable": await n("i.release_id IS NOT NULL AND (i.title IS NULL OR i.weight_g IS NULL) " + "AND EXISTS (SELECT 1 FROM disc_cache dc WHERE dc.release_id=i.release_id " + "AND (dc.title IS NOT NULL OR dc.weight IS NOT NULL))"), + "api_needed": await nd(_NEEDS_API), # distinct releases needing Discogs + } + + +class HealIn(BaseModel): + limit: int = 60 # cap Discogs fetches per run (rate limit) — re-run for the remainder + + +@router.post("/heal/run") +async def heal_run(body: HealIn = HealIn(), ident=Depends(require_token), db=Depends(get_db)): + sid = ident["store_id"] + # Phase 1 — local backfill from the mirror (instant, unbounded) + local = (await db.execute(text(""" + UPDATE inventory i SET title = COALESCE(i.title, dc.title), + weight_g = COALESCE(i.weight_g, dc.weight), updated_at = now() + FROM disc_cache dc + WHERE dc.release_id = i.release_id AND i.store_id = :sid AND i.in_stock + AND (i.title IS NULL OR i.weight_g IS NULL) + AND (dc.title IS NOT NULL OR dc.weight IS NOT NULL)"""), {"sid": sid})).rowcount + await db.commit() + + # Phase 2 — bounded Discogs re-fetch for releases missing from the mirror / without cover + rids = [r[0] for r in (await db.execute(text( + f"SELECT DISTINCT i.release_id FROM inventory i " + f"WHERE i.store_id=:sid AND i.in_stock AND {_NEEDS_API} LIMIT :lim"), + {"sid": sid, "lim": body.limit}))] + enriched = 0 + for rid in rids: + meta = await _fetch_release(db, rid) # always fetches → fills cover + grows mirror + if meta: + await db.execute(text( + "UPDATE inventory SET title=COALESCE(title,:t), weight_g=COALESCE(weight_g,:w), " + "updated_at=now() WHERE release_id=:r AND store_id=:sid AND in_stock"), + {"t": meta.get("title"), "w": meta.get("weight"), "r": rid, "sid": sid}) + enriched += 1 + await asyncio.sleep(0.2) # ponytail: gentle on the Discogs rate limit + await db.commit() + + remaining = (await db.execute(text( + f"SELECT count(DISTINCT i.release_id) FROM inventory i " + f"WHERE i.store_id=:sid AND i.in_stock AND {_NEEDS_API}"), {"sid": sid})).scalar() + return {"ok": True, "local_healed": local, "api_enriched": enriched, "remaining_api": remaining} + + def _selfcheck(): # SKU from a Google-Form timestamp (both ISO and en-AU slash formats) → YYYYMMDDHHMMSS assert _sku_from_ts("2025-01-30T14:01:02.821Z") == "20250130140102" diff --git a/site/admin.html b/site/admin.html index 4e2d105..2ed0281 100644 --- a/site/admin.html +++ b/site/admin.html @@ -71,6 +71,7 @@ 📝 Wantlist
Scans in-stock items for gaps — missing title, weight, or cover art — and backfills from your local Discogs mirror. Anything the mirror doesn’t have is re-fetched from Discogs (rate-limited, so it heals in batches).
' + + ' ' + + '