An item can carry several credits blocks with mid- and post-credit scenes between them, and Plex flags the one that really ends it. The player left at the first block. Now the Plex-final block moves on (by the auto-skip or the Skip button), an earlier block is only skipped to its end by the button, and with no final flag the last block ends the item by the button alone.
548 lines
23 KiB
Python
548 lines
23 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 datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.collation import collated, sorted_words
|
|
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.query import title_col
|
|
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."""
|
|
lang = _norm_lang(lang)
|
|
hu = 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), 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:
|
|
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, 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,
|
|
}
|
|
|
|
|
|
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 = {
|
|
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"), 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 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.
|
|
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 = (
|
|
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(), collated(title_col(PlexItem, lang), lang)
|
|
)
|
|
.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), 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 = 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)
|
|
|
|
|
|
# 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 = (
|
|
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),
|
|
# 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:
|
|
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_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 = 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, 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,
|
|
}
|