~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.
519 lines
22 KiB
Python
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,
|
|
}
|