The first checkpoint of an item and the final flush beside it both read no state and both inserted; the loser failed on uq_plex_user_item. Two first saves each made a Watch later list. Both are created by an ON CONFLICT DO NOTHING insert and then read; a partial unique index holds one Watch later per user, and the migration folds an existing second list into the first. Plex keeps one per-item state map (rollup.item_state_map).
472 lines
21 KiB
Python
472 lines
21 KiB
Python
"""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 = rollup.item_state_map(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 = rollup.item_state_map(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.id)
|
|
|
|
# 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,
|
|
}
|