"""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, HTTPException from sqlalchemy import func, select from sqlalchemy.orm import Session 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 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 (PlexError, PlexNotConfigured): 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 HTTPException(status_code=404, detail="Unknown Plex 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: db.rollback() except Exception: # 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 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: 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, } ) except Exception: pass # live extras (markers/streams) are best-effort; any live-Plex failure must degrade, not 500 — playback works without them # 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() 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, "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, }