114 exception handlers neither logged nor re-raised. The defect was never that
they are silent — plenty of them should be — it is that their silence said
nothing, so nobody could tell a decision from an oversight. Each one has now been
read. 44 got a line; the other 70 carry `# silent-ok: <why>`.
What the silent ones had been hiding, in the cases that got a line:
- A failed ffprobe sent every HDR file down the 8-bit pipeline, so playback
degraded to a software transcode with nothing saying why; a failed audio probe
collapsed a multi-audio file to one track and the player showed no language
picker; a failed first-PTS probe left subtitles seconds out of sync.
- A subtitle sidecar that existed but would not parse made a PARTIAL track read as
complete, permanently — the viewer loses every cue past the cached window.
- An unreadable crop/trim/cut-list in an edit spec was dropped, so the clip came
out uncropped, untrimmed, or shorter than the one the user built.
- A dropped `max_height` downloaded at best quality against an explicit cap.
- A failed videos.list enrich made every candidate undroppable-from-judgement and
therefore dropped: a working search rendered as "no results".
- An unusable password hash answered as "wrong password", locking the owner out
and telling them they mistyped; a rotated TOKEN_ENCRYPTION_KEY turned every
stored token into "not connected".
- A stored admin setting that would not coerce fell back to the env default while
the settings page went on showing the saved value.
- A one-off InnerTube config failure pinned the whole process to the hard-coded
fallback key, and searches return nothing when that goes stale.
- The HLS sweep reported 0 swept — like a clean run — when it could not list its
own root, leaving every crashed session's segments on disk forever.
Four of them are counted rather than logged per item (dropped cues, dropped cut
list segments, unreclaimed GC files) and reported once per call, because the
alternative is one line per cue over a whole subtitle track.
ruff gains S110 and BLE001. Every blind `except Exception` that remains carries
`# noqa: BLE001 — <why>`, which is the point: catching everything is sometimes
right, and always worth a sentence. plex/sync.py's three best-effort backfill
catches now keep the traceback — a blind catch over a batch that fetches AND
writes could be Plex, the database or our own bug, and `%s` of the exception told
them apart badly. main.py's boot-time log-level read likewise.
logs.event's `code` is positional-only now: `event("x", code=...)` was a TypeError,
and `code` is exactly the word a caller reaches for when the thing that failed has
one of its own.
tests/test_swallowed_exceptions.py is the wider rule ruff cannot see: a handler
that neither logs nor re-raises must say why. Both cases mutation-checked — a
marker removed and a log line removed each fail it, naming the handler.
500 lines
21 KiB
Python
500 lines
21 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 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 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
|
|
|
|
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."""
|
|
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: # 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 (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:
|
|
# 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)
|
|
|
|
|
|
@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: # noqa: BLE001 — playback works without the extras; never 500 here
|
|
# Best-effort, but not free: what this 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.
|
|
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),
|
|
)
|
|
|
|
# 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,
|
|
}
|
|
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),
|
|
# 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,
|
|
}
|