"""Plex show/item detail: the show drill-down + the per-item player payload (markers, cast, nav).""" import re import threading import time from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.collation import sorted_words from app import logs from app.auth import current_user from app.db import get_db from app.discover import queries as discover_queries from app.models import User from app.plex import detail as plex_detail from app.plex import queries, rollup, service, subtitles from app.plex import tmdb as plex_tmdb from app.plex.collections import collection_source from app.plex.cards import SHOW_NEW, episode_card, movie_card, show_card from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.routes.plex._shared import _norm_lang, item_or_404 from app.routes._common import not_found if TYPE_CHECKING: from app.plex.models import PlexItem, PlexSeason log = logs.get_logger(__name__) router = APIRouter(prefix="/api/plex", tags=["plex"]) _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.""" lang = _norm_lang(lang) hu = lang == "hu" sh = queries.show(db, rating_key) if sh is None: raise not_found("show") seasons = queries.seasons(db, sh) eps = queries.episodes(db, sh) states = queries.states(db, user, [e.id for e in eps]) 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), lang=lang) 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). 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, lang=lang) if sh.people_enriched_at is None and meta: service.enrich_show(db, sh, meta) # imdb/tagline/full_cast for future views 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": queries.library_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, served from the row. "tmdb": plex_tmdb.serve_block(db, sh, lang=lang), }, "seasons": [_season_block(se, by_season.get(se.id, [])) for se in seasons], "related": related, } @router.get("/show/{rating_key}/next") def show_next( rating_key: str, lang: str = "en", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """The episode a show's card plays: the one in progress, else the first unwatched, else the first (`discover.queries.next_episode`, the rule Home's shelves and the show page's Resume follow). Asked one show at a time, when a card is hovered or played, rather than carried on every card of a grid: three probes a show would cost a 60-card page 180 queries for the few cards anyone plays. `next` is null for a show with no episodes.""" sh = queries.show(db, rating_key) if sh is None: raise not_found("show") return {"next": discover_queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))} 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, lang: str ) -> 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 = queries.shows_by_keys(db, rks[:60], exclude) 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, SHOW_NEW), lang=lang)) if len(out) >= 20: break return out def _collection_strips(db: Session, it: "PlexItem", user_id: int, lang: str) -> 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 queries.collections_by_keys(db, keys)} # Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last. alpha = {w: i for i, w in enumerate(sorted_words(db, [c.title for c in cols.values()], lang))} ordered = sorted(cols.values(), key=lambda c: (c.child_count or 0, alpha[c.title])) out = [] for col in ordered: rk = col.rating_key members = queries.library_movies_in(db, it.library_id, rk, lang, limit=60) states = queries.states(db, user_id, [m.id for m in members]) out.append( { "id": rk, "title": col.title, "source": collection_source(col), "items": [movie_card(m, states.get(m.id), lang=lang) 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 = queries.episode_keys(db, it.show_id) 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) # The technical tail of Plex's track title — "English (AC3 5.1)" → "AC3 5.1". _TRACK_TAIL = re.compile(r"\(([^()]+)\)\s*$") def _track_naming(s: dict) -> dict: """What the player names a track from. Plex's `displayTitle` is in the SERVER's language ("English (AC3 5.1)", "magyar Forced", "中文 (EAC3 Stereo)"), so it is sent only as the fallback `label`; the client builds the name in the reader's language from the language tag, the forced / SDH flags and the codec tail. No title and no language → no label (the client numbers it).""" title = s.get("displayTitle") or s.get("language") or None tail = _TRACK_TAIL.search(title) if title else None return { "label": title, "lang_tag": s.get("languageTag") or None, "forced": bool(s.get("forced")), "hearing_impaired": bool(s.get("hearingImpaired")), "detail": tail.group(1).strip() if tail 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 = queries.state(db, user, it) # 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), # An item can carry several credits blocks with scenes between them; Plex # flags the one that really ends it. Which credits may leave the item is the # player's rule (`lib/plexMarkers`), so the flag goes over as Plex sent it. "final": bool(m.get("final")), } ) # 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), **_track_naming(s), "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), **_track_naming(s), "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: service.enrich_movie(db, it, meta) 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_hu or it.tagline) if hu else it.tagline, } else: rich = plex_detail.rich_detail(meta) prev_id, next_id = _episode_nav(db, it) show = queries.show_by_id(db, it.show_id) if it.kind == "episode" and it.show_id else None season = queries.season_by_id(db, it.season_id) if show and it.season_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": queries.library_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), # The episode page links up to its show and its season (their rating keys = the view keys). "show_id": show.rating_key if show else None, "season_id": season.rating_key if season 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, lang), # TMDB enrichment, served from the row — movies only (episodes aren't enriched). "tmdb": plex_tmdb.serve_block(db, it, lang=lang) if it.kind == "movie" else None, }