refactor(e5): deferred review-tail (backend) — enrich off request path, reuse, tests

Address the deep-review's deferred backend items so E5 is review-complete:

- person-enrich OFF the request path (ABUSE finding): person_detail no longer makes
  the TMDB call inline (even bounded to 8s it sat on the serve thread, un-throttled,
  demo-reachable — it violated the module's "no live TMDB call at serve time"
  invariant). It now schedules a FastAPI BackgroundTask (people._enrich_person_bg,
  fresh session, best-effort) after the response; the first open serves the row as-is
  and the bio lands by the next open. The inline try/rollback recovery is gone with it.

- reuse: a shared tmdb.resolve_library_guids owns the collision-safe guid→library
  bucketing (movie vs tv ids are independent sequences) that resolve_recs and
  people._resolve_known_for each hand-duplicated; a new rollup.item_state_map dedups
  the "PlexState-by-item_id" fetch across the person + discover paths.

- test gaps: enrich_batch transient TmdbError leaves the watermark NULL to retry;
  guid_to_tmdb_id resolves a tvdb source + tv media_type via /find's tv_results;
  _continue_watching includes a NULL-status started movie and excludes a hidden one;
  /api/search/youtube returns 422 (not 5xx) when ingest fails (route-level, B-core-1).

Backend lane 403 → 413, ruff clean. No migration; serve contracts unchanged.
This commit is contained in:
2026-08-21 06:09:17 +02:00
parent 5316f48af2
commit 13fb2b4e61
8 changed files with 263 additions and 120 deletions
@@ -93,3 +93,63 @@ def test_scrape_ingest_enrich_miss_keeps_nonnull_placeholder(db):
c = db.get(Video, "vid_ccccccc")
assert c.published_at is not None # placeholder wrote a real value → no crash
assert c.enriched_at is None
def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch):
# Route-level guard (B-core-1): an ingest failure with nothing yet collected must surface as a 422
# + message on this JSON endpoint (the FE hides 5xx behind a generic "connection lost" + auto-retry,
# burning a second quota-costed search), and the session is rolled back. Here the page fetch succeeds
# (one candidate) and _ingest_candidates raises → the endpoint returns 422.
from fastapi.testclient import TestClient
import app.state as state
from app.auth import require_human
from app.db import get_db
from app.main import app
from app.routes import search as search_mod
user = User(email="route422@example.com")
db.add(user)
db.commit()
monkeypatch.setattr(state, "_configured_cache", True)
monkeypatch.setattr(
search_mod.sysconfig, "get_str", lambda d, k: "scrape" if k == "search_source" else ""
)
monkeypatch.setattr(search_mod.sysconfig, "get_int", lambda d, k: 100) # daily cap not reached
monkeypatch.setattr(search_mod.quota, "actions_today", lambda d, uid, action: 0)
class _FakeYTCM:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
monkeypatch.setattr(search_mod, "YouTubeClient", _FakeYTCM)
monkeypatch.setattr(
search_mod.search_scrape,
"search",
lambda term, continuation=None: {
"items": [_candidate("vid_route12", "UC_route_a")],
"next_page_token": None,
},
)
def _boom(db_, yt, user_, candidates):
raise RuntimeError("ingest exploded")
monkeypatch.setattr(search_mod, "_ingest_candidates", _boom)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[require_human] = lambda: user
try:
resp = TestClient(app).get("/api/search/youtube?q=test+term")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 422, resp.text
assert "YouTube search failed" in resp.json()["detail"]