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.
439 lines
18 KiB
Python
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},
|
|
]
|