Files
siftlode/backend/app/routes/plex/detail.py
T
peter 7ae5c57b6b fix(i18n): every refusal the API answers is structured and said in the reader's language
~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.

- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
  reason, …params}; localizeDetail translates errors.refused.<reason> with the
  params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
  English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
  answer a reason; the search scraper's failure is logged and answered as
  youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
  (only the framework's own can remain) is left out, so reading .detail is
  safe everywhere — including destructured, the hole the raw-detail source
  scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
  for a detail; the contract test requires every refused reason and every
  not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
  is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
  lockfile lines.
2026-09-19 07:34:17 +02:00

519 lines
22 KiB
Python

"""Plex show/item detail: the show drill-down + the per-item player payload (markers, cast, nav)."""
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import logs
from app.auth import current_user
from app.db import get_db
from app.models import (
PlexCollection,
PlexItem,
PlexLibrary,
PlexSeason,
PlexShow,
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 tmdb as plex_tmdb
from app.plex.cards import episode_card, movie_card, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
from app.routes.plex._shared import _norm_lang, item_or_404
from app.routes.plex.collections import _collection_source
from app.routes._common import not_found
log = logs.get_logger(__name__)
router = APIRouter(prefix="/api/plex", tags=["plex"])
def _lib_key(db: Session, library_id: int | None) -> str | None:
"""The Plex section key for a library id — the info/show page passes it to the admin collection
editor (in the unified cross-library view there's no single library prop otherwise)."""
if library_id is None:
return None
lib = db.get(PlexLibrary, library_id)
return lib.plex_key if lib else None
_SHOW_ENRICH_TTL_S = 300
_show_enrich_cache: dict[str, tuple[float, dict, list]] = {}
_show_enrich_lock = threading.Lock()
def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]:
"""(meta, related_raw) for a show's live Plex enrichment — served from the per-show TTL cache when
warm, otherwise fetched with the two Plex calls (metadata + related) run IN PARALLEL. Returns
({}, []) when Plex is unavailable/unconfigured (not cached, so it retries on the next open)."""
now = time.monotonic()
with _show_enrich_lock:
hit = _show_enrich_cache.get(rating_key)
if hit is not None and hit[0] > now:
return hit[1], hit[2]
try:
with PlexClient(db) as plex:
with ThreadPoolExecutor(max_workers=2) as ex:
meta_f = ex.submit(plex.metadata, rating_key)
related_f = ex.submit(plex.related, rating_key) # swallows its own errors → []
meta = meta_f.result() or {}
related_raw = related_f.result()
except PlexNotConfigured:
# silent-ok: not a failure but a state — this instance has no Plex server, and the page
# is meant to render from the local rows alone.
return {}, []
except PlexError as exc:
# The detail page still renders, from the rows the sync left behind: no cast, no related
# strip, no rich detail. Indistinguishable on screen from a show that simply has none.
log.warning(
"live Plex enrichment failed for a show",
extra=logs.event(
"plex.detail.show_enrich_failed",
rating_key=rating_key,
reason=type(exc).__name__,
),
)
return {}, []
# Don't cache an EMPTY related list: plex.related() swallows a transient failure as [] (same as a
# genuinely no-related show), so caching it would serve an empty "related" section for the whole TTL
# even after Plex recovers. Skip the cache in that case (retry next open, as before); shows that DO
# have related items — the payload worth caching — still get cached.
if related_raw:
with _show_enrich_lock:
_show_enrich_cache[rating_key] = (
now + _SHOW_ENRICH_TTL_S,
meta,
related_raw,
)
return meta, related_raw
@router.get("/show/{rating_key}")
def show_detail(
rating_key: str,
lang: str = "en",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""A show's seasons + episodes (the drill-down view) with per-user watch state."""
hu = _norm_lang(lang) == "hu"
sh = by_rating_key(db, PlexShow, rating_key)
if sh is None:
raise not_found("show")
seasons = (
db.execute(
select(PlexSeason)
.where(PlexSeason.show_id == sh.id)
.order_by(PlexSeason.season_number.asc().nullsfirst())
)
.scalars()
.all()
)
eps = (
db.execute(
select(PlexItem)
.where(PlexItem.show_id == sh.id, PlexItem.kind == "episode")
.order_by(
PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
)
.scalars()
.all()
)
states = {
s.item_id: s
for s in db.execute(
select(PlexState).where(
PlexState.user_id == user.id,
PlexState.item_id.in_([e.id for e in eps] or [0]),
)
).scalars()
}
by_season: dict[int | None, list] = {}
all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup
for e in eps:
card = episode_card(e, states.get(e.id))
by_season.setdefault(e.season_id, []).append(card)
all_cards.append(card)
show_roll = rollup.rollup(all_cards)
# 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)
related = _relatedshow_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: # noqa: BLE001 — a failed enrich must not fail the page
# The rollback un-does the enrichment AND the `people_enriched_at` stamp, so the
# next view tries again — but if the commit keeps failing nobody ever hears, and
# the cast section stays empty for good.
log.warning(
"the show enrichment could not be committed",
extra=logs.event(
"plex.detail.enrich_commit_failed", rating_key=sh.rating_key, kind="show"
),
)
db.rollback()
except Exception: # noqa: BLE001 — the local episode list must render whatever Plex does
# Best-effort only — the local episode list is already assembled, so any live-Plex failure
# (not configured, HTTP/JSON shape) must degrade, not 500. With the traceback, because what
# is left in here after the Plex errors above are handled is our own shape bugs.
log.warning(
"the live part of a show page could not be built",
extra=logs.event("plex.detail.show_extras_failed", rating_key=sh.rating_key),
exc_info=True,
)
imdb_id = sh.imdb_id
return {
"show": {
"id": sh.rating_key,
"title": (sh.title_hu or sh.title) if hu else sh.title,
"summary": (sh.summary_hu or sh.summary) if hu else sh.summary,
"year": sh.year,
"thumb": f"/api/plex/image/{sh.rating_key}",
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
"library": _lib_key(db, sh.library_id),
"content_rating": sh.content_rating,
"rating": sh.rating,
"genres": (sh.genres_hu or sh.genres if hu else sh.genres) or [],
"studio": sh.studio,
"season_count": sh.child_count,
"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"],
"first": show_roll["first"],
"episode_count": show_roll["episode_count"],
"collection_keys": sh.collection_keys or [],
# TMDB enrichment (E5 S2), served from the row.
"tmdb": plex_tmdb.serve_block(db, sh),
},
"seasons": [_season_block(se, by_season.get(se.id, [])) for se in seasons],
"related": related,
}
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
season subpage, read from the same cached show-detail payload)."""
roll = rollup.rollup(cards)
return {
"id": se.rating_key,
"season_number": se.season_number,
"title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"),
"thumb": f"/api/plex/image/{se.rating_key}",
"episode_count": roll["episode_count"],
"status": roll["status"],
"resume": roll["resume"],
"first": roll["first"],
"episodes": cards,
}
def _relatedshow_cards(db: Session, user_id: int, related: list[dict], exclude: int) -> list[dict]:
"""Map Plex 'related' metadata to show cards for the shows we actually mirror (so they're openable),
excluding the current show. Order preserved; capped."""
rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")]
if not rks:
return []
shows = {
s.rating_key: s
for s in db.execute(
select(PlexShow).where(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude)
).scalars()
}
statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()])
out: list[dict] = []
seen: set[str] = set()
for rk in rks:
sh = shows.get(rk)
if sh is None or rk in seen:
continue
seen.add(rk)
out.append(show_card(sh, statuses.get(sh.id, "new")))
if len(out) >= 20:
break
return out
def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]:
"""The collections this movie belongs to, each with its member titles (playable cards) — the
'Avatar Collection' strip on the info page. Pure local lookup (no Plex call)."""
keys = it.collection_keys or []
if not keys or it.kind != "movie":
return []
cols = {
c.rating_key: c
for c in db.execute(
select(PlexCollection).where(PlexCollection.rating_key.in_(keys))
).scalars()
}
# Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last.
ordered = sorted((c for c in cols.values()), key=lambda c: (c.child_count or 0, c.title))
out = []
for col in ordered:
rk = col.rating_key
members = (
db.execute(
select(PlexItem)
.where(
PlexItem.library_id == it.library_id,
PlexItem.kind == "movie",
PlexItem.collection_keys.contains([rk]),
)
.order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title))
.limit(60)
)
.scalars()
.all()
)
ids = [m.id for m in members]
states = {
s.item_id: s
for s in db.execute(
select(PlexState).where(
PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0])
)
).scalars()
}
out.append(
{
"id": rk,
"title": col.title,
"source": _collection_source(col),
"items": [movie_card(m, states.get(m.id)) for m in members],
}
)
return out
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
if it.kind != "episode" or it.show_id is None:
return None, None
keys = list(
db.execute(
select(PlexItem.rating_key)
.where(PlexItem.show_id == it.show_id, PlexItem.kind == "episode")
.order_by(
PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
).scalars()
)
try:
i = keys.index(it.rating_key)
except ValueError:
# An episode missing from its own show's ordered list — the two came out of the same sync,
# so this is a gap in it. The page loses its previous/next arrows and says nothing.
log.info(
"an episode is not in its show's episode list; no previous/next",
extra=logs.event(
"plex.detail.episode_nav_missing", rating_key=it.rating_key, show_id=it.show_id
),
)
return None, None
return (keys[i - 1] if i > 0 else None), (keys[i + 1] if i < len(keys) - 1 else None)
@router.get("/item/{rating_key}")
def item_detail(
rating_key: str,
lang: str = "en",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode
prev/next, and the per-user resume position + watch status."""
lang = _norm_lang(lang)
it = item_or_404(db, rating_key)
st = (
db.execute(
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
)
.scalars()
.first()
)
# 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 {}
for m in meta.get("Marker") or []:
if m.get("type") in ("intro", "credits"):
markers.append(
{
"type": m.get("type"),
"start_s": round((m.get("startTimeOffset") or 0) / 1000, 1),
"end_s": round((m.get("endTimeOffset") or 0) / 1000, 1),
}
)
# Audio + subtitle tracks (ordinals among their stream type — used to -map the selection).
part = ((meta.get("Media") or [{}])[0].get("Part") or [{}])[0]
for s in part.get("Stream") or []:
if s.get("streamType") == 2:
audio_streams.append(
{
"ord": len(audio_streams),
"label": s.get("displayTitle")
or s.get("language")
or f"Audio {len(audio_streams) + 1}",
"language": s.get("languageTag") or s.get("language"),
"default": bool(s.get("selected") or s.get("default")),
}
)
elif s.get("streamType") == 3:
codec = (s.get("codec") or s.get("format") or "").lower()
subtitle_streams.append(
{
"ord": len(subtitle_streams),
"label": s.get("displayTitle")
or s.get("language")
or f"Subtitle {len(subtitle_streams) + 1}",
"language": s.get("languageTag") or s.get("language"),
"codec": codec,
# Text subs (srt/ass/…) can be served as WebVTT tracks; image subs (PGS/VobSub)
# can't be turned into text, so the player hides them (burn-in is a later phase).
"text": codec not in subtitles.BITMAP_SUBS,
}
)
# Best-effort, but not free: what either branch drops is the subtitle stream list and the intro
# markers, so the player offers no subtitles and no skip button — and looks like a file that has
# neither.
except PlexNotConfigured:
# silent-ok: not a failure but a state — no Plex server, and nothing to play from anyway.
pass
except PlexError as exc:
log.warning(
"the live extras for an item could not be fetched",
extra=logs.event(
"plex.detail.extras_failed",
rating_key=it.rating_key,
kind=it.kind,
reason=type(exc).__name__,
),
)
except Exception: # noqa: BLE001 — playback works without the extras; never 500 here
# Plex answered and WE could not read it — a shape our parsing does not expect. The same
# split `show_detail` makes: the stack is the only thing that says where.
log.warning(
"the live extras for an item could not be read",
extra=logs.event(
"plex.detail.extras_unreadable", rating_key=it.rating_key, kind=it.kind
),
exc_info=True,
)
# 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: # noqa: BLE001 — a failed enrich must not fail the page
# Same as the show path above: the info page then serves the three names the listing
# sync stored, and the full cast it just fetched is thrown away unreported.
log.warning(
"the movie enrichment could not be committed",
extra=logs.event(
"plex.detail.enrich_commit_failed", rating_key=it.rating_key, kind="movie"
),
)
db.rollback()
hu = lang == "hu"
# 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_hu or it.genres if hu else 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 {
"id": it.rating_key,
"kind": it.kind,
"title": (it.title_hu or it.title) if hu else it.title,
"summary": (it.summary_hu or it.summary) if hu else it.summary,
"year": it.year,
"duration_seconds": it.duration_s,
"playable": it.playable,
# Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows.
"transcode_hw": it.transcode_hw,
"codec_video": it.codec_video,
"video_bit_depth": it.video_bit_depth,
"thumb": f"/api/plex/image/{it.rating_key}",
"art": f"/api/plex/image/{it.rating_key}?variant=art",
"library": _lib_key(db, it.library_id),
"cast": rich.get("cast", []),
"imdb_rating": rich.get("imdb_rating"),
"imdb_id": rich.get("imdb_id"),
"imdb_url": rich.get("imdb_url"),
# Audience score (Plex audienceRating) served alongside the IMDb score — the UI shows the
# IMDb badge when imdb_rating is present, else this. Episodes carry it on the row too.
"rating": it.rating,
"content_rating": rich.get("content_rating"),
"genres": rich.get("genres", []),
"directors": rich.get("directors", []),
"studio": rich.get("studio"),
"tagline": rich.get("tagline"),
"markers": markers,
"audio_streams": audio_streams,
"subtitle_streams": subtitle_streams,
"status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0,
"show_title": ((show.title_hu or show.title if hu else show.title) if show else None),
"season_number": it.season_number,
"episode_number": it.episode_number,
"prev_id": prev_id,
"next_id": next_id,
"collections": _collection_strips(db, it, user.id),
# TMDB enrichment (E5 S2), served from the row — movies only (episodes aren't enriched).
"tmdb": plex_tmdb.serve_block(db, it) if it.kind == "movie" else None,
}