Files
siftlode/backend/tests/test_plex_detail.py
T
peter 0cda5b6dd4 fix(player): only the credits that end an item move on to the next one
An item can carry several credits blocks with mid- and post-credit scenes between
them, and Plex flags the one that really ends it. The player left at the first
block. Now the Plex-final block moves on (by the auto-skip or the Skip button), an
earlier block is only skipped to its end by the button, and with no final flag the
last block ends the item by the button alone.
2026-10-07 01:48:10 +02:00

439 lines
18 KiB
Python

"""Regression: the info-page rich detail (IMDb score/id, tagline, full cast) is persisted at
rich-fetch time and served from the DB row instead of a live plex.metadata() call.
- `rich_detail`/`apply_rich_detail` are pure — parse + persist the rich-only fields.
- `_enrich_people_batch` (the cast backfill) now also stamps those fields.
- `item_detail` serves a MOVIE's detail from the row (survives a live-Plex outage) while keeping
markers/streams live; an EPISODE (never enriched) still parses detail from the live meta.
- `show_detail` serves from the row; `related` stays live best-effort.
The endpoint tests use the DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from types import SimpleNamespace
from fastapi.testclient import TestClient
import app.routes.plex.detail as detail_routes
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexShow, User
from app.plex import detail
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 tagline, and a two-person cast (one with a CDN headshot → proxied, one without → thumb None).
RICH_META = {
"Rating": [
{"image": "imdb://image.rating.imdb", "value": 8.7},
{"image": "themoviedb://image.rating", "value": 7.0},
],
"audienceRating": 9.9, # ignored: the imdb Rating entry is preferred
"Guid": [{"id": "tmdb://238"}, {"id": "imdb://tt0068646"}],
"contentRating": "R",
"Genre": [{"tag": "Crime"}, {"tag": "Drama"}],
"Director": [{"tag": "Francis Ford Coppola"}],
"studio": "Paramount",
"tagline": "An offer you can't refuse.",
"Role": [
{
"tag": "Marlon Brando",
"role": "Vito Corleone",
"thumb": "https://metadata-static.plex.tv/b.jpg",
},
{"tag": "Al Pacino", "role": "Michael"},
],
}
# ---- pure parse/persist --------------------------------------------------------------------------
def test_rich_detail_prefers_imdb_entry_and_builds_link():
d = detail.rich_detail(RICH_META)
assert d["imdb_rating"] == 8.7 # the imdb Rating entry, not audienceRating (9.9)
assert d["imdb_id"] == "tt0068646"
assert d["imdb_url"] == "https://www.imdb.com/title/tt0068646/"
assert d["tagline"] == "An offer you can't refuse."
assert [c["name"] for c in d["cast"]] == ["Marlon Brando", "Al Pacino"]
assert d["cast"][0]["role"] == "Vito Corleone"
assert d["cast"][0]["thumb"] and d["cast"][0]["thumb"].startswith("/api/plex/person-image")
assert d["cast"][1]["thumb"] is None # no CDN headshot → not proxied
def test_rich_detail_is_imdb_only_no_audience_fallback():
# Imdb_rating is IMDb-ONLY. With no imdb:// Rating entry it's NULL — the Plex audience score
# is NOT mislabelled as an IMDb score (it's served separately via the `rating` column).
d = detail.rich_detail({"audienceRating": 7.25})
assert d["imdb_rating"] is None
assert d["imdb_id"] is None and d["imdb_url"] is None
def test_apply_rich_detail_writes_only_rich_fields():
row = SimpleNamespace(imdb_rating=None, imdb_id=None, tagline=None, full_cast=None)
detail.apply_rich_detail(row, RICH_META)
assert row.imdb_rating == 8.7
assert row.imdb_id == "tt0068646"
assert row.tagline == "An offer you can't refuse."
assert [c["name"] for c in row.full_cast] == ["Marlon Brando", "Al Pacino"]
def test_apply_rich_detail_empty_cast_persists_null():
row = SimpleNamespace(imdb_rating=1.0, imdb_id="x", tagline="t", full_cast=[{"name": "stale"}])
detail.apply_rich_detail(row, {}) # a poor/empty meta must clear to NULL, not keep stale data
assert row.full_cast is None
assert row.imdb_rating is None and row.imdb_id is None and row.tagline is None
# ---- backfill persistence (DB lane) --------------------------------------------------------------
class _StubClient:
"""Stand-in for PlexClient: `_enrich_people_batch` calls plex.metadata(rating_key); item_detail
uses it as a context manager and passes markers=True. `raise_` simulates a Plex outage."""
def __init__(self, meta: dict | None = None, raise_: bool = False):
self._meta = meta or {}
self._raise = raise_
def __enter__(self):
return self
def __exit__(self, *a):
return False
def metadata(self, rating_key, markers=False):
if self._raise:
raise RuntimeError("plex unavailable")
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 the 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_apply_facet_fields_keeps_over_cap_cast_after_a_watermark_reset():
"""The enriched-cast guard must survive a watermark RESET (people_enriched_at nulled, e.g. by a
migration like 0065). A stored cast LONGER than the listing cap can only be an enriched fuller list,
so it's kept even though the row now reads as un-enriched — comparing against the cap, not the
current listing's length, is what makes this robust (regression guard for the review fix)."""
thin = {"Role": [{"tag": f"Actor {i}"} for i in range(20)]} # a full 20-name listing
over_cap = SimpleNamespace(
people_enriched_at=None, # watermark was reset
directors=None,
cast_names=[f"Enriched {i}" for i in range(22)], # 22 > the 20 cap => enriched
)
_apply_facet_fields(over_cap, thin)
assert over_cap.cast_names == [f"Enriched {i}" for i in range(22)] # kept, not downgraded
def test_apply_facet_fields_tracks_a_trimmed_listing_when_unenriched():
"""A listing-sourced cast (at or below the cap, never enriched) must TRACK Plex cast edits in both
directions. When Plex trims the cast, the shorter fresh listing replaces the stored one — the old
length-vs-this-listing guard would have wrongly kept the stale longer cast."""
stored = SimpleNamespace(
people_enriched_at=None,
directors=None,
cast_names=["A", "B", "C", "D", "E"], # 5, at/below the cap => listing-sourced
)
trimmed = {"Role": [{"tag": "A"}, {"tag": "B"}]} # Plex removed 3 cast members
_apply_facet_fields(stored, trimmed)
assert stored.cast_names == ["A", "B"]
def test_enrich_people_batch_persists_detail(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather")
db.add(mv)
db.commit()
assert mv.people_enriched_at is None and mv.full_cast is None
n = _enrich_people_batch(db, _StubClient(RICH_META))
assert n == 1
db.refresh(mv)
assert mv.imdb_rating == 8.7
assert mv.imdb_id == "tt0068646"
assert mv.tagline == "An offer you can't refuse."
assert [c["name"] for c in mv.full_cast] == ["Marlon Brando", "Al Pacino"]
assert mv.cast_names == ["Marlon Brando", "Al Pacino"] # people enrich still runs alongside
assert mv.people_enriched_at is not None # stamped so the backfill skips it next run
# ---- item_detail serving (DB lane) ---------------------------------------------------------------
def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user)
return TestClient(app).get(path)
def _user(db, email):
u = User(email=email)
db.add(u)
db.flush()
return u
def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch, override_dep):
"""An enriched movie serves cast/IMDb/tagline from the DB row even when the live Plex call raises —
only markers/streams (which need the live meta) degrade."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
from datetime import datetime, timezone
mv = PlexItem(
rating_key="m1",
library_id=lib.id,
kind="movie",
title="The Godfather",
genres=["Crime", "Drama"],
directors=["Francis Ford Coppola"],
studio="Paramount",
content_rating="R",
imdb_rating=8.7,
imdb_id="tt0068646",
tagline="An offer you can't refuse.",
full_cast=[{"name": "Marlon Brando", "role": "Vito Corleone", "thumb": None}],
people_enriched_at=datetime.now(timezone.utc),
)
db.add(mv)
db.commit()
user = _user(db, "outage@example.com")
db.commit()
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(raise_=True))
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["imdb_rating"] == 8.7
assert body["imdb_id"] == "tt0068646"
assert body["imdb_url"] == "https://www.imdb.com/title/tt0068646/"
assert body["tagline"] == "An offer you can't refuse."
assert [c["name"] for c in body["cast"]] == ["Marlon Brando"]
assert body["genres"] == ["Crime", "Drama"]
assert body["markers"] == [] # live fetch failed → markers degrade, but detail survived
def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch, override_dep):
"""A not-yet-enriched movie is filled from the live meta on view (and persisted for next time)."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather")
db.add(mv)
db.commit()
user = _user(db, "lazy@example.com")
db.commit()
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1")
assert resp.status_code == 200, resp.text
assert resp.json()["imdb_id"] == "tt0068646"
db.refresh(mv)
assert mv.imdb_id == "tt0068646" # persisted
assert mv.people_enriched_at is not None
assert [c["name"] for c in mv.full_cast] == ["Marlon Brando", "Al Pacino"]
def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep):
"""Episodes are never enriched (not in the grid, no HU overlay), so their detail is parsed from the
live meta the endpoint fetches for markers/streams."""
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.flush()
ep = PlexItem(
rating_key="e1", library_id=lib.id, kind="episode", title="Pilot", show_id=show.id
)
db.add(ep)
db.commit()
user = _user(db, "ep@example.com")
db.commit()
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/e1")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row
assert [c["name"] for c in body["cast"]] == ["Marlon Brando", "Al Pacino"]
db.refresh(ep)
assert ep.full_cast is None # episodes are not persisted/enriched
def test_show_detail_serves_from_row(db, monkeypatch, override_dep):
"""A show serves cast/IMDb from the DB row; `related` failing (Plex down) must not drop the detail."""
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add(lib)
db.flush()
from datetime import datetime, timezone
show = PlexShow(
rating_key="s1",
library_id=lib.id,
title="A Show",
genres=["Drama"],
imdb_rating=9.1,
imdb_id="tt0903747",
full_cast=[{"name": "Bryan Cranston", "role": "Walter White", "thumb": None}],
people_enriched_at=datetime.now(timezone.utc),
)
db.add(show)
db.commit()
user = _user(db, "show@example.com")
db.commit()
# _show_enrichment raises → related degrades to []; the row-served detail must still render.
def _boom(db_, rk):
raise RuntimeError("plex unavailable")
monkeypatch.setattr(detail_routes, "_show_enrichment", _boom)
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/s1")
assert resp.status_code == 200, resp.text
body = resp.json()["show"]
assert body["imdb_rating"] == 9.1
assert body["imdb_id"] == "tt0903747"
assert body["imdb_url"] == "https://www.imdb.com/title/tt0903747/"
assert [c["name"] for c in body["cast"]] == ["Bryan Cranston"]
assert resp.json()["related"] == []
def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch, override_dep):
"""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(detail_routes, "_show_enrichment", lambda db_, rk: (RICH_META, []))
resp = _get(db, user, monkeypatch, override_dep, "/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"]
def test_item_detail_names_tracks_from_their_language_not_plexs_title(
db, monkeypatch, override_dep
):
"""Plex titles a track in the Plex SERVER's language ("English (AC3 5.1)", "magyar Forced",
"中文 (EAC3 Stereo)"), so a Hungarian reader got a mix. The route sends what the client names it
from instead — the language tag, the forced / SDH flags, the codec tail — keeping Plex's title
only as the fallback, and no invented English "Audio 2" for a track with neither."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="T"))
db.commit()
user = _user(db, "tracks@example.com")
db.commit()
streams = [
{"streamType": 2, "displayTitle": "English (AC3 5.1)", "languageTag": "en-US"},
{"streamType": 2, "displayTitle": "中文 (EAC3 Stereo)", "languageTag": "zh"},
{"streamType": 2},
{
"streamType": 3,
"displayTitle": "magyar Forced",
"languageTag": "hu",
"forced": True,
"codec": "srt",
},
{
"streamType": 3,
"displayTitle": "English SDH",
"languageTag": "en",
"hearingImpaired": True,
"codec": "srt",
},
]
meta = {**RICH_META, "Media": [{"Part": [{"Stream": streams}]}]}
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(meta))
body = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1").json()
naming = ("label", "lang_tag", "forced", "hearing_impaired", "detail")
assert [tuple(a[k] for k in naming) for a in body["audio_streams"]] == [
("English (AC3 5.1)", "en-US", False, False, "AC3 5.1"),
("中文 (EAC3 Stereo)", "zh", False, False, "EAC3 Stereo"),
(None, None, False, False, None),
]
assert [tuple(s[k] for k in naming) for s in body["subtitle_streams"]] == [
("magyar Forced", "hu", True, False, None),
("English SDH", "en", False, True, None),
]
def test_item_detail_markers_carry_plex_final_flag(db, monkeypatch, override_dep):
"""An item can carry several credits markers — mid- and post-credit scenes sit between them —
and only the one Plex flags `final` is the real end; the player needs that flag to know which
credits may move on to the next item."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Iron Man"))
db.commit()
user = _user(db, "markers@example.com")
db.commit()
meta = {
"Marker": [
{"type": "intro", "startTimeOffset": 1000, "endTimeOffset": 60000},
{"type": "credits", "startTimeOffset": 6811000, "endTimeOffset": 6831000},
{
"type": "credits",
"startTimeOffset": 7549000,
"endTimeOffset": 7559000,
"final": True,
},
{"type": "commercial", "startTimeOffset": 1, "endTimeOffset": 2},
]
}
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(meta))
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m2")
assert resp.status_code == 200, resp.text
assert resp.json()["markers"] == [
{"type": "intro", "start_s": 1.0, "end_s": 60.0, "final": False},
{"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False},
{"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True},
]