feat(plex): S5b persist rich detail + serve item/show detail from DB
Persist the info-page rich fields that were previously read LIVE from plex.metadata() on every view —
imdb_rating (IMDb-specific, vs `rating`=audienceRating), imdb_id, tagline, and full_cast
([{name,role,thumb}]) — onto plex_items/plex_shows, and serve the detail endpoints from the row.
- migration 0065: ADD imdb_rating/imdb_id/tagline/full_cast (nullable → no rewrite/lock, unlike 0064);
NULL people_enriched_at on movie/show rows so the existing cast backfill re-fetches each once and
fills the new columns.
- new app/plex/detail.py: rich_detail(meta) (moved out of browse._rich_meta so sync + serve share one
parser) + apply_rich_detail(row, meta); wired into _enrich_people_batch and the info-page lazy fill.
- item_detail: movies serve cast/IMDb/tagline/genres from the row and keep plex.metadata(markers=True)
ONLY for markers + audio/subtitle streams (so detail survives a live-Plex outage); episodes (never
enriched) still parse detail from that live meta.
- show_detail: serves cast/IMDb from the row; `related` stays live best-effort and also lazily fills a
not-yet-enriched show.
- tests: rich_detail/apply_rich_detail parsing + persistence, backfill stamping, item_detail
outage-resilience + lazy fill + episode-live, show_detail row serving. Full suite 285 pass / 2 skip;
migration verified reversible (up→down→up).
Precondition for S6's pure-DB language-aware serving.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
"""plex rich-detail item/show columns (E5 S5b)
|
||||
|
||||
On plex_items and plex_shows: the fields that were previously read LIVE from plex.metadata() on every
|
||||
info-page view — `imdb_rating` (the IMDb-specific score, distinct from `rating` = audienceRating),
|
||||
`imdb_id` (tt…), `tagline`, and `full_cast` (ordered [{name, role, thumb}]). Persisting them lets the
|
||||
detail endpoints serve from the DB row instead of a live Plex call (outage-resilient, and the
|
||||
precondition for S6's pure-DB language-aware serving).
|
||||
|
||||
These are filled by the same rich backfill that already fills the full cast (`_enrich_people_batch`),
|
||||
so the data step below resets `people_enriched_at` to NULL on movie/show rows: the backfill re-fetches
|
||||
each once (60/run, self-throttled) and stamps the new columns. ADD-only, all nullable → no table
|
||||
rewrite, no generated column, no ACCESS EXCLUSIVE lock (unlike 0064).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0065_plex_detail_fields"
|
||||
down_revision: Union[str, None] = "0064_plex_hu_overlay_cols"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_TABLES = ("plex_items", "plex_shows")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for t in _TABLES:
|
||||
op.add_column(t, sa.Column("imdb_rating", sa.Float(), nullable=True))
|
||||
op.add_column(t, sa.Column("imdb_id", sa.String(16), nullable=True))
|
||||
op.add_column(t, sa.Column("tagline", sa.Text(), nullable=True))
|
||||
op.add_column(t, sa.Column("full_cast", postgresql.JSONB(), nullable=True))
|
||||
# Force a one-time re-enrich so already-enriched rows gain the new columns (episodes are never
|
||||
# enriched, so their NULL people_enriched_at is unaffected; they keep serving detail live).
|
||||
op.execute("UPDATE plex_items SET people_enriched_at = NULL WHERE kind = 'movie'")
|
||||
op.execute("UPDATE plex_shows SET people_enriched_at = NULL")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for t in _TABLES:
|
||||
op.drop_column(t, "full_cast")
|
||||
op.drop_column(t, "tagline")
|
||||
op.drop_column(t, "imdb_id")
|
||||
op.drop_column(t, "imdb_rating")
|
||||
@@ -1048,6 +1048,12 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
||||
people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector
|
||||
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
||||
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so
|
||||
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
||||
imdb_rating: Mapped[float | None] = mapped_column(Float) # IMDb-specific score (vs `rating` = audienceRating)
|
||||
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
||||
tagline: Mapped[str | None] = mapped_column(Text)
|
||||
full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}]
|
||||
# Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched,
|
||||
# so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors.
|
||||
title_hu: Mapped[str | None] = mapped_column(String(512))
|
||||
@@ -1159,6 +1165,12 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
||||
people_text: Mapped[str | None] = mapped_column(Text)
|
||||
# Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
||||
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so
|
||||
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
||||
imdb_rating: Mapped[float | None] = mapped_column(Float) # IMDb-specific score (vs `rating` = audienceRating)
|
||||
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
||||
tagline: Mapped[str | None] = mapped_column(Text)
|
||||
full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}]
|
||||
# Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched
|
||||
# (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors.
|
||||
title_hu: Mapped[str | None] = mapped_column(String(512))
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that
|
||||
the detail endpoints serve from the DB row (E5 S5b).
|
||||
|
||||
Shared by the live info-page path (routes.plex.browse) and the background rich re-sync (plex.sync) so
|
||||
the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent.
|
||||
`rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY fields onto a movie/show row
|
||||
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
|
||||
|
||||
from app.plex import people
|
||||
|
||||
|
||||
def rich_detail(meta: dict) -> dict:
|
||||
"""Pull the info-page extras out of a full Plex metadata dict: an IMDb score + link, content
|
||||
rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any
|
||||
missing field is just omitted."""
|
||||
# Rating: prefer the explicit IMDb entry in the Rating array, else the generic audienceRating.
|
||||
imdb_rating = None
|
||||
for r in meta.get("Rating") or []:
|
||||
if str(r.get("image") or "").startswith("imdb://"):
|
||||
try:
|
||||
imdb_rating = round(float(r.get("value")), 1)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
if imdb_rating is None:
|
||||
try:
|
||||
imdb_rating = round(float(meta.get("audienceRating") or meta.get("rating")), 1)
|
||||
except (TypeError, ValueError):
|
||||
imdb_rating = None
|
||||
# IMDb id (tt…) from the Guid list → a clickable link.
|
||||
imdb_id = None
|
||||
for g in meta.get("Guid") or []:
|
||||
gid = str(g.get("id") or "")
|
||||
if gid.startswith("imdb://"):
|
||||
imdb_id = gid.split("imdb://", 1)[1]
|
||||
break
|
||||
return {
|
||||
"imdb_rating": imdb_rating,
|
||||
"imdb_id": imdb_id,
|
||||
"imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None,
|
||||
"content_rating": meta.get("contentRating"),
|
||||
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
|
||||
"directors": [
|
||||
d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")
|
||||
][:4],
|
||||
"studio": meta.get("studio"),
|
||||
"tagline": meta.get("tagline"),
|
||||
"cast": people.cast_from_meta(meta),
|
||||
}
|
||||
|
||||
|
||||
def apply_rich_detail(row, meta: dict) -> None:
|
||||
"""Persist the rich-ONLY detail fields onto a movie/show row so the info page serves them from the
|
||||
DB instead of a live Plex call. Call wherever a rich metadata dict is fetched (the cast backfill +
|
||||
the info-page lazy enrich). Empty cast persists as SQL NULL (none_as_null), so an EN fallback and
|
||||
the `full_cast IS NULL` "not yet enriched" signal stay meaningful."""
|
||||
d = rich_detail(meta)
|
||||
row.imdb_rating = d["imdb_rating"]
|
||||
row.imdb_id = d["imdb_id"]
|
||||
row.tagline = d["tagline"]
|
||||
row.full_cast = d["cast"] or None
|
||||
@@ -17,7 +17,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
|
||||
from app.plex import people
|
||||
from app.plex import detail, people
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
|
||||
@@ -258,6 +258,7 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT
|
||||
log.debug("Plex people enrich fetch failed for %s: %s", row.rating_key, e)
|
||||
continue # leave people_enriched_at NULL so it's retried on a later run
|
||||
people.enrich_row_people(db, row, people.cast_from_meta(meta))
|
||||
detail.apply_rich_detail(row, meta) # imdb/tagline/full_cast → served from the row, not live
|
||||
row.people_enriched_at = now
|
||||
n += 1
|
||||
db.commit()
|
||||
|
||||
@@ -39,6 +39,7 @@ from app.models import (
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.plex import detail as plex_detail
|
||||
from app.plex import people as plex_people
|
||||
from app.plex import rollup, subtitles
|
||||
from app.plex import sync as plex_sync
|
||||
@@ -1392,31 +1393,28 @@ def show_detail(
|
||||
all_cards.append(card)
|
||||
show_roll = rollup.rollup(all_cards)
|
||||
|
||||
# Cast + IMDb (best-effort live metadata; same shape as the movie/episode info page). The two live
|
||||
# Plex calls are cached per-show + fetched in parallel by _show_enrichment; the per-user shaping
|
||||
# (_related_show_cards adds this user's watch-state) stays out of the shared cache.
|
||||
rich: dict = {}
|
||||
# Cast + IMDb + tagline are served from the DB row (persisted by the cast backfill, E5 S5b). The one
|
||||
# remaining live call is `related` — a Plex recommendation hub we don't mirror — which also carries
|
||||
# the metadata used to lazily fill a not-yet-enriched show. Any live failure just drops `related` and
|
||||
# the lazy fill; the row-served detail below still renders (outage-resilient).
|
||||
related: list[dict] = []
|
||||
try:
|
||||
meta, related_raw = _show_enrichment(db, sh.rating_key)
|
||||
rich = _rich_meta(meta)
|
||||
related = _related_show_cards(db, user.id, related_raw, exclude=sh.id)
|
||||
if sh.people_enriched_at is None and meta:
|
||||
plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta))
|
||||
plex_detail.apply_rich_detail(sh, meta) # imdb/tagline/full_cast for future views
|
||||
sh.people_enriched_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
# Best-effort enrichment only — the local episode list is already assembled, so any live-Plex
|
||||
# failure (not configured, HTTP/JSON shape, _rich_meta parse error) must degrade, not 500.
|
||||
# Best-effort only — the local episode list is already assembled, so any live-Plex failure
|
||||
# (not configured, HTTP/JSON shape) must degrade, not 500.
|
||||
pass
|
||||
|
||||
# Lazy enrichment: persist the show's full cast so its cast members become filterable (same as the
|
||||
# movie info page — the sync only stored the listing's top ~3), and stamp people_enriched_at so the
|
||||
# backfill skips it. Best-effort.
|
||||
if rich.get("cast"):
|
||||
plex_people.enrich_row_people(db, sh, rich["cast"])
|
||||
sh.people_enriched_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
imdb_id = sh.imdb_id
|
||||
return {
|
||||
"show": {
|
||||
"id": sh.rating_key,
|
||||
@@ -1431,10 +1429,10 @@ def show_detail(
|
||||
"genres": sh.genres or [],
|
||||
"studio": sh.studio,
|
||||
"season_count": sh.child_count,
|
||||
"imdb_rating": rich.get("imdb_rating"),
|
||||
"imdb_id": rich.get("imdb_id"),
|
||||
"imdb_url": rich.get("imdb_url"),
|
||||
"cast": rich.get("cast", []),
|
||||
"imdb_rating": sh.imdb_rating,
|
||||
"imdb_id": imdb_id,
|
||||
"imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None,
|
||||
"cast": sh.full_cast or [],
|
||||
# Show-level rollup for the hero action buttons.
|
||||
"status": show_roll["status"],
|
||||
"resume": show_roll["resume"],
|
||||
@@ -1495,48 +1493,6 @@ def _related_show_cards(
|
||||
return out
|
||||
|
||||
|
||||
def _rich_meta(meta: dict) -> dict:
|
||||
"""Pull the "info page" extras out of a full Plex metadata dict: an IMDb score + link, content
|
||||
rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any
|
||||
missing field is just omitted."""
|
||||
# Rating: prefer the explicit IMDb entry in the Rating array, else the generic audienceRating.
|
||||
imdb_rating = None
|
||||
for r in meta.get("Rating") or []:
|
||||
if str(r.get("image") or "").startswith("imdb://"):
|
||||
try:
|
||||
imdb_rating = round(float(r.get("value")), 1)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
if imdb_rating is None:
|
||||
try:
|
||||
imdb_rating = round(
|
||||
float(meta.get("audienceRating") or meta.get("rating")), 1
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
imdb_rating = None
|
||||
# IMDb id (tt…) from the Guid list → a clickable link.
|
||||
imdb_id = None
|
||||
for g in meta.get("Guid") or []:
|
||||
gid = str(g.get("id") or "")
|
||||
if gid.startswith("imdb://"):
|
||||
imdb_id = gid.split("imdb://", 1)[1]
|
||||
break
|
||||
return {
|
||||
"imdb_rating": imdb_rating,
|
||||
"imdb_id": imdb_id,
|
||||
"imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None,
|
||||
"content_rating": meta.get("contentRating"),
|
||||
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
|
||||
"directors": [
|
||||
d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")
|
||||
][:4],
|
||||
"studio": meta.get("studio"),
|
||||
"tagline": meta.get("tagline"),
|
||||
"cast": plex_people.cast_from_meta(meta),
|
||||
}
|
||||
|
||||
|
||||
def _content_libs(db: Session) -> tuple[list[int], list[int]]:
|
||||
"""The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
|
||||
source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never
|
||||
@@ -1767,14 +1723,16 @@ def item_detail(
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
rich: dict = {}
|
||||
# Markers + audio/subtitle streams are file-specific, so they stay LIVE; the rich detail (cast/IMDb/
|
||||
# tagline/genres) is served from the DB row for movies (persisted below / by the backfill) and only
|
||||
# parsed from this live meta for episodes (never enriched — not in the grid, no HU overlay).
|
||||
meta: dict = {}
|
||||
markers: list[dict] = []
|
||||
audio_streams: list[dict] = []
|
||||
subtitle_streams: list[dict] = []
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
meta = plex.metadata(it.rating_key, markers=True) or {}
|
||||
rich = _rich_meta(meta)
|
||||
for m in meta.get("Marker") or []:
|
||||
if m.get("type") in ("intro", "credits"):
|
||||
markers.append(
|
||||
@@ -1814,20 +1772,39 @@ def item_detail(
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass # metadata extras are best-effort; any live-Plex failure must degrade, not 500 — playback works without them
|
||||
pass # live extras (markers/streams) are best-effort; any live-Plex failure must degrade, not 500 — playback works without them
|
||||
|
||||
# Lazy enrichment: the rich fetch above carries the FULL cast (the sync only stored the listing's
|
||||
# top ~3), so persist it onto this movie so every cast member shown here becomes filterable, and
|
||||
# stamp people_enriched_at so the backfill skips it. Movie only — episodes aren't in the grid; a
|
||||
# show's cast is enriched from /show. Best-effort.
|
||||
if it.kind == "movie" and rich.get("cast"):
|
||||
plex_people.enrich_row_people(db, it, rich["cast"])
|
||||
# Lazy enrichment (movies only): the live fetch above carries the FULL cast + rich detail (the sync
|
||||
# only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable
|
||||
# and future views serve from the row. Episodes aren't enriched; a show's cast is filled from /show.
|
||||
if it.kind == "movie" and it.people_enriched_at is None and meta:
|
||||
cast = plex_people.cast_from_meta(meta)
|
||||
if cast:
|
||||
plex_people.enrich_row_people(db, it, cast)
|
||||
plex_detail.apply_rich_detail(it, meta)
|
||||
it.people_enriched_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
# Movies serve rich detail from the DB row; episodes from the live meta just fetched.
|
||||
if it.kind == "movie":
|
||||
_imdb_id = it.imdb_id
|
||||
rich = {
|
||||
"cast": it.full_cast or [],
|
||||
"imdb_rating": it.imdb_rating,
|
||||
"imdb_id": _imdb_id,
|
||||
"imdb_url": f"https://www.imdb.com/title/{_imdb_id}/" if _imdb_id else None,
|
||||
"content_rating": it.content_rating,
|
||||
"genres": it.genres or [],
|
||||
"directors": it.directors or [],
|
||||
"studio": it.studio,
|
||||
"tagline": it.tagline,
|
||||
}
|
||||
else:
|
||||
rich = plex_detail.rich_detail(meta)
|
||||
|
||||
prev_id, next_id = _episode_nav(db, it)
|
||||
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Regression (E5 S5b): 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.browse as browse
|
||||
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 _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_falls_back_to_audience_rating():
|
||||
d = detail.rich_detail({"audienceRating": 7.25})
|
||||
assert d["imdb_rating"] == 7.2 # rounded to 1dp
|
||||
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_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, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
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):
|
||||
"""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(browse, "PlexClient", lambda db: _StubClient(raise_=True))
|
||||
resp = _get(db, user, monkeypatch, "/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):
|
||||
"""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(browse, "PlexClient", lambda db: _StubClient(RICH_META))
|
||||
resp = _get(db, user, monkeypatch, "/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):
|
||||
"""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(browse, "PlexClient", lambda db: _StubClient(RICH_META))
|
||||
resp = _get(db, user, monkeypatch, "/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):
|
||||
"""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(browse, "_show_enrichment", _boom)
|
||||
resp = _get(db, user, monkeypatch, "/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"] == []
|
||||
Reference in New Issue
Block a user