feat(plex): Plex-web-style 3-level series view (Phase 2 of series view)

Restructure TV browsing into show detail → seasons → season episodes → player, like
the Plex web app.

Backend: /show/{rk} now returns a rich show page — hero meta (rating/content_rating/
genres/studio + live IMDb), live Cast & Crew, Related shows (Plex 'related', mapped to
our mirrored shows so they're openable), and per-season cards with an aggregate
watch-state + on-deck episode, plus show-level resume (on-deck)/first(play-from-start)/
status rollup. New PlexClient.related(). New bulk-state endpoints POST /show/{rk}/state
and /season/{rk}/state mark every episode watched/unwatched for the user and mirror
each change to a linked Plex account in the background (best-effort, checked once).

Frontend: PlexShowView reworked into the show detail page (hero + Resume/Play-from-start/
Mark-show-watched/Add-to-playlist/[admin]Add-to-collection + season card grid + cast +
related strips); new PlexSeasonView season subpage (hero + Resume/Play/Mark-season/
Add-season-to-playlist + landscape episode grid). Both read the one cached ['plex-show']
payload (season page picks its season out of it — instant, no extra fetch). Player queue =
the whole show (from the show page) or the season (from the season page) so prev/next +
auto-advance follow order. New 'season' history subview; Backspace steps back one drill
level (grid←show←season, out of info/playlist) alongside browser/mouse Back. Season-level
'Add to collection' intentionally omitted (Plex collections hold whole shows, not seasons).
i18n plex.series.* (en/hu/de).
This commit is contained in:
npeter83 2026-07-10 22:08:04 +02:00
parent 569d31235d
commit 11b7558c6c
7 changed files with 694 additions and 102 deletions

View file

@ -96,6 +96,18 @@ class PlexClient:
mc = self._get(f"/library/metadata/{rating_key}/children")
return mc.get("Metadata", []) or []
def related(self, rating_key: str) -> list[dict]:
"""Related / similar items for a movie or show, flattened out of Plex's Hub grouping.
Best-effort (needs the library's related data) — returns [] if Plex has none."""
try:
mc = self._get(f"/library/metadata/{rating_key}/related")
except PlexError:
return []
out: list[dict] = []
for hub in mc.get("Hub", []) or []:
out.extend(hub.get("Metadata", []) or [])
return out
def collections(self, section_key: str) -> list[dict]:
"""All collections in a library section (title/summary/thumb/childCount/smart)."""
mc = self._get(f"/library/sections/{section_key}/collections")

View file

@ -250,6 +250,26 @@ def _show_status(total: int, watched: int, inprog: int) -> str:
return "new"
def _rollup(cards: list[dict]) -> dict:
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
last in-progress one, else the first unwatched), the first episode (for Play from the start), and
the episode count. Drives the show/season Resume + Play + mark-all buttons."""
total = len(cards)
watched = sum(1 for c in cards if c.get("status") == "watched")
inprog = [c for c in cards if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"]
resume = None
if inprog:
resume = inprog[-1] # continue the latest-started episode
else:
resume = next((c for c in cards if c.get("status") != "watched"), None)
return {
"status": _show_status(total, watched, len(inprog)),
"resume": resume,
"first": cards[0] if cards else None,
"episode_count": total,
}
def _show_agg_subq(db: Session, user_id: int):
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
grid by aggregate watch-state and (b) compute each card's badge."""
@ -1033,8 +1053,24 @@ def show_detail(
)
}
by_season: dict[int | None, list] = {}
all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup
for e in eps:
by_season.setdefault(e.season_id, []).append(_episode_card(e, states.get(e.id)))
card = _episode_card(e, states.get(e.id))
by_season.setdefault(e.season_id, []).append(card)
all_cards.append(card)
show_roll = _rollup(all_cards)
# Cast + IMDb (best-effort live metadata; same shape as the movie/episode info page).
rich: dict = {}
related: list[dict] = []
try:
with PlexClient(db) as plex:
meta = plex.metadata(sh.rating_key) or {}
rich = _rich_meta(meta)
related = _related_show_cards(db, user.id, plex.related(sh.rating_key), exclude=sh.id)
except (PlexError, PlexNotConfigured):
pass
return {
"show": {
"id": sh.rating_key,
@ -1043,20 +1079,71 @@ def show_detail(
"year": sh.year,
"thumb": f"/api/plex/image/{sh.rating_key}",
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
"content_rating": sh.content_rating,
"rating": sh.rating,
"genres": sh.genres or [],
"studio": sh.studio,
"season_count": sh.child_count,
"imdb_rating": rich.get("imdb_rating"),
"imdb_id": rich.get("imdb_id"),
"imdb_url": rich.get("imdb_url"),
"cast": rich.get("cast", []),
# 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 [],
},
"seasons": [
{
"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}",
"episodes": by_season.get(se.id, []),
}
_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(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 _related_show_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.query(PlexShow).filter(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude)
}
statuses = _show_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 _image_key(db: Session, rating_key: str, variant: str) -> str | None:
"""The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys
are proxyable this is not an open image proxy."""
@ -1695,6 +1782,81 @@ def item_state(
return {"status": status}
def _bulk_state(
background: BackgroundTasks, db: Session, user_id: int, eps: list[PlexItem], watched: bool
) -> int:
"""Mark every episode in `eps` watched (or unwatched) for a user, mirroring each change to a
linked Plex account in the background (best-effort). Returns how many rows actually changed."""
now = datetime.now(timezone.utc)
existing = {
s.item_id: s
for s in db.query(PlexState).filter(
PlexState.user_id == user_id, PlexState.item_id.in_([e.id for e in eps] or [0])
)
}
pushable = plex_watch.link_for_push(db, user_id) is not None
to_push: list[tuple[int, str, str]] = []
for e in eps:
st = existing.get(e.id)
prev = st.status if st is not None else "new"
if watched:
if st is None:
st = PlexState(user_id=user_id, item_id=e.id)
db.add(st)
st.status = "watched"
st.watched_at = now
st.position_seconds = 0
st.synced_to_plex = False
if prev != "watched":
to_push.append((e.id, e.rating_key, "watched"))
else: # → new: drop any state (but keep the user's private "hidden" marks)
if st is not None and prev != "hidden":
db.delete(st)
if prev != "new":
to_push.append((e.id, e.rating_key, "unwatched"))
db.commit()
if pushable:
for item_id, rk, action in to_push:
background.add_task(plex_watch.push_state_to_plex, user_id, item_id, rk, action, 0, 0)
return len(to_push)
@router.post("/show/{rating_key}/state")
def show_state(
rating_key: str,
payload: dict,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex."""
sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first()
if sh is None:
raise HTTPException(status_code=404, detail="Unknown Plex show")
watched = bool(payload.get("watched"))
eps = db.query(PlexItem).filter_by(show_id=sh.id, kind="episode").all()
changed = _bulk_state(background, db, user.id, eps, watched)
return {"changed": changed, "watched": watched}
@router.post("/season/{rating_key}/state")
def season_state(
rating_key: str,
payload: dict,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex."""
se = db.query(PlexSeason).filter_by(rating_key=str(rating_key)).first()
if se is None:
raise HTTPException(status_code=404, detail="Unknown Plex season")
watched = bool(payload.get("watched"))
eps = db.query(PlexItem).filter_by(season_id=se.id, kind="episode").all()
changed = _bulk_state(background, db, user.id, eps, watched)
return {"changed": changed, "watched": watched}
@router.post("/stream/{rating_key}/session")
def stream_session(
rating_key: str,