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
+18
View File
@@ -76,3 +76,21 @@ def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))
).all()
return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
def item_state_map(
db: Session, user_id: int, item_ids: list[int]
) -> dict[int, PlexState]:
"""A user's PlexState rows for a set of leaf items (movies or episodes), keyed by item_id — the
leaf analogue of `state_map` (which rolls a show's episodes up to a status string). One batched
fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`)."""
if not item_ids:
return {}
return {
s.item_id: s
for s in db.execute(
select(PlexState).where(
PlexState.user_id == user_id, PlexState.item_id.in_(item_ids)
)
).scalars()
}
+50 -29
View File
@@ -344,40 +344,61 @@ def run_enrich(db: Session) -> dict:
return {"enriched": n}
def resolve_recs(db: Session, recs: list[dict] | None) -> list[dict]:
"""Tag each recommendation with whether it's in OUR library (matched by guid `tmdb:<id>`) and, if so,
its rating_key — so the client can deep-link the ones we have and merely display the rest.
def resolve_library_guids(
db: Session,
entries: list[dict] | None,
*,
movie_lib_ids: list[int] | None = None,
show_lib_ids: list[int] | None = None,
) -> tuple[dict[str, PlexItem], dict[str, PlexShow]]:
"""Match a list of TMDB credits/recs (each `{tmdb_id, media_type, …}`) to our library rows by the
canonical guid `tmdb:<id>`, returning `(movies_by_guid, shows_by_guid)` of the ORM rows.
TMDB movie ids and tv ids are independent sequences (id 603 can be BOTH a movie and a show), so the
match is bucketed by each rec's own media_type: a movie rec only matches a library movie, a tv rec
only a library show — otherwise a numeric-id collision would deep-link a rec to an unrelated title."""
if not recs:
return []
⭐ TMDB movie ids and tv ids are INDEPENDENT sequences (id 603 can be BOTH a movie and a show), so
each entry matches ONLY its own media_type's table — a movie credit against a library movie, a tv
credit against a library show; a blind numeric match would resolve to an unrelated same-id title.
Pass `movie_lib_ids`/`show_lib_ids` to scope to the enabled libraries (the person page); leave them
None to match across all libraries (the detail-page recs). An explicit empty list means "that side
has no enabled library" → its bucket is skipped entirely."""
if not entries:
return {}, {}
movie_guids = [
f"tmdb:{r['tmdb_id']}" for r in recs if r.get("tmdb_id") and r.get("media_type") != "tv"
f"tmdb:{e['tmdb_id']}" for e in entries if e.get("tmdb_id") and e.get("media_type") != "tv"
]
tv_guids = [
f"tmdb:{r['tmdb_id']}" for r in recs if r.get("tmdb_id") and r.get("media_type") == "tv"
f"tmdb:{e['tmdb_id']}" for e in entries if e.get("tmdb_id") and e.get("media_type") == "tv"
]
movie_lib: dict[str, str] = {}
show_lib: dict[str, str] = {}
if movie_guids:
for guid, rk in db.execute(
select(PlexItem.guid, PlexItem.rating_key).where(
PlexItem.kind == "movie", PlexItem.guid.in_(movie_guids)
)
):
movie_lib.setdefault(guid, rk)
if tv_guids:
for guid, rk in db.execute(
select(PlexShow.guid, PlexShow.rating_key).where(PlexShow.guid.in_(tv_guids))
):
show_lib.setdefault(guid, rk)
movies: dict[str, PlexItem] = {}
if movie_guids and movie_lib_ids != []:
conds = [PlexItem.kind == "movie", PlexItem.guid.in_(movie_guids)]
if movie_lib_ids is not None:
conds.append(PlexItem.library_id.in_(movie_lib_ids))
for m in db.execute(select(PlexItem).where(*conds)).scalars():
movies.setdefault(m.guid, m)
shows: dict[str, PlexShow] = {}
if tv_guids and show_lib_ids != []:
conds = [PlexShow.guid.in_(tv_guids)]
if show_lib_ids is not None:
conds.append(PlexShow.library_id.in_(show_lib_ids))
for s in db.execute(select(PlexShow).where(*conds)).scalars():
shows.setdefault(s.guid, s)
return movies, shows
def resolve_recs(db: Session, recs: list[dict] | None) -> list[dict]:
"""Tag each recommendation with whether it's in OUR library (matched by guid `tmdb:<id>`, bucketed
by media_type — see resolve_library_guids) and, if so, its rating_key — so the client can deep-link
the ones we have and merely display the rest."""
if not recs:
return []
movies, shows = resolve_library_guids(db, recs)
out: list[dict] = []
for r in recs:
g = f"tmdb:{r.get('tmdb_id')}"
rk = show_lib.get(g) if r.get("media_type") == "tv" else movie_lib.get(g)
out.append({**r, "in_library": rk is not None, "rating_key": rk})
row = shows.get(g) if r.get("media_type") == "tv" else movies.get(g)
out.append(
{**r, "in_library": row is not None, "rating_key": row.rating_key if row else None}
)
return out
@@ -453,9 +474,9 @@ def enrich_person(db: Session, person: PlexPerson) -> bool:
if not person.tmdb_person_id or not sysconfig.get_str(db, "tmdb_api_key"):
return False
try:
# This runs on the request thread (first person-page open), so cap the outbound TMDB call with
# a short timeout — a slow/hanging TMDB must not tie up a worker for the default 30s. (The fuller
# fix — moving person enrichment off the request path — is logged in ROADMAP under E5.)
# Called from a background task (off the request path — see people._enrich_person_bg), so it no
# longer blocks a serve; still cap the outbound TMDB call with a short timeout so a hanging TMDB
# doesn't tie up a threadpool worker for the default 30s.
with TmdbClient(db, timeout=8.0) as tmdb:
payload = tmdb.fetch_person(person.tmdb_person_id)
except TmdbError: