fix(plex): S5b review — preserve enriched cast_names across full syncs

Address the /code-review high finding on S5b: gating the info-page cast enrich on
`people_enriched_at is None` removed the old per-view regrow of `cast_names`, so a routine full sync
that reset it to the listing top-20 (`_apply_facet_fields`) — while never clearing people_enriched_at —
left it permanently truncated, silently dropping cast past the top ~20 from the S4 person-page filter.

Root fix: `_apply_facet_fields` now only takes the listing's cast for a NOT-yet-enriched row; an
enriched row keeps its fuller stored cast (people_text still rebuilt from the updated directors + the
preserved cast). Makes the people_enriched_at flag the endpoints/backfill trust actually reliable.

Tests: cast_names preserved for an enriched row vs applied for a fresh one; show_detail lazy-fill
persists imdb/full_cast on first view. Full suite green.
This commit is contained in:
2026-08-16 23:30:33 +02:00
parent 4e97fc1a84
commit 81827fcf28
2 changed files with 55 additions and 2 deletions
+6 -1
View File
@@ -304,7 +304,12 @@ def _apply_facet_fields(row, meta: dict) -> None:
row.originally_available_at = _date(meta.get("originallyAvailableAt")) row.originally_available_at = _date(meta.get("originallyAvailableAt"))
row.genres = _tags(meta, "Genre") row.genres = _tags(meta, "Genre")
row.directors = _tags(meta, "Director") row.directors = _tags(meta, "Director")
row.cast_names = _tags(meta, "Role", limit=20) # The listing carries only the top ~20 cast, so re-applying it would DOWNGRADE an already-enriched
# full cast (up to 24, grown by the rich backfill/info-page enrich) — and since a sync never clears
# `people_enriched_at`, nothing would regrow it, silently breaking the person-page cast filter for
# names past the top ~20. Only take the listing's cast before the row is enriched; keep it after.
if row.people_enriched_at is None:
row.cast_names = _tags(meta, "Role", limit=20)
people = list(dict.fromkeys((row.directors or []) + (row.cast_names or []))) people = list(dict.fromkeys((row.directors or []) + (row.cast_names or [])))
row.people_text = " ".join(people) or None row.people_text = " ".join(people) or None
+49 -1
View File
@@ -20,7 +20,7 @@ from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary, PlexShow, User from app.models import PlexItem, PlexLibrary, PlexShow, User
from app.plex import detail from app.plex import detail
from app.plex.sync import _enrich_people_batch from app.plex.sync import _apply_facet_fields, _enrich_people_batch
# A full Plex metadata dict: an explicit IMDb Rating entry (must win over audienceRating), an IMDb Guid, # A full Plex metadata dict: an explicit IMDb Rating entry (must win over audienceRating), an IMDb Guid,
# a tagline, and a two-person cast (one with a CDN headshot → proxied, one without → thumb None). # a tagline, and a two-person cast (one with a CDN headshot → proxied, one without → thumb None).
@@ -101,6 +101,31 @@ class _StubClient:
return self._meta return self._meta
def test_apply_facet_fields_preserves_enriched_cast_names():
"""A full sync must NOT downgrade an already-enriched full cast back to the listing top-20 — since
sync never clears people_enriched_at, nothing would regrow it and the person-page filter would lose
the names past the listing cap (regression guard for E5 S5b's enrich gating)."""
# The cheap listing's top few cast + the (always-present) director.
thin = {"Role": [{"tag": "Al Pacino"}, {"tag": "James Caan"}], "Director": [{"tag": "Francis Ford Coppola"}]}
# Not-yet-enriched row: the listing cast IS applied.
fresh = SimpleNamespace(people_enriched_at=None, directors=None, cast_names=None)
_apply_facet_fields(fresh, thin)
assert fresh.cast_names == ["Al Pacino", "James Caan"]
# Enriched row: the fuller stored cast is KEPT, not overwritten by the thin listing.
from datetime import datetime, timezone
enriched = SimpleNamespace(
people_enriched_at=datetime.now(timezone.utc),
directors=["Francis Ford Coppola"],
cast_names=["Marlon Brando", "Al Pacino", "James Caan", "Diane Keaton"],
)
_apply_facet_fields(enriched, thin)
assert enriched.cast_names == ["Marlon Brando", "Al Pacino", "James Caan", "Diane Keaton"]
# people_text is rebuilt from the (updated) directors + the PRESERVED full cast.
assert "Diane Keaton" in enriched.people_text and "Francis Ford Coppola" in enriched.people_text
def test_enrich_people_batch_persists_detail(db): def test_enrich_people_batch_persists_detail(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
@@ -251,3 +276,26 @@ def test_show_detail_serves_from_row(db, monkeypatch):
assert body["imdb_url"] == "https://www.imdb.com/title/tt0903747/" assert body["imdb_url"] == "https://www.imdb.com/title/tt0903747/"
assert [c["name"] for c in body["cast"]] == ["Bryan Cranston"] assert [c["name"] for c in body["cast"]] == ["Bryan Cranston"]
assert resp.json()["related"] == [] assert resp.json()["related"] == []
def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch):
"""A not-yet-enriched show is filled from the live meta on first view (imdb/full_cast persisted +
stamped) so subsequent views serve from the row."""
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add(lib)
db.flush()
show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show")
db.add(show)
db.commit()
user = _user(db, "showfill@example.com")
db.commit()
# _show_enrichment returns (live meta, no related) → the fill branch runs.
monkeypatch.setattr(browse, "_show_enrichment", lambda db_, rk: (RICH_META, []))
resp = _get(db, user, monkeypatch, "/api/plex/show/s1")
assert resp.status_code == 200, resp.text
assert resp.json()["show"]["imdb_id"] == "tt0068646"
db.refresh(show)
assert show.imdb_id == "tt0068646" # persisted for next time
assert show.people_enriched_at is not None
assert [c["name"] for c in show.full_cast] == ["Marlon Brando", "Al Pacino"]