Mechanical, whole-tree `ruff format` pass — no behaviour change. Recorded in .git-blame-ignore-revs (next commit) so git blame skips it.
443 lines
18 KiB
Python
443 lines
18 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, 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,
|
|
}
|