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,

View file

@ -1,18 +1,33 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, CheckCircle2, Info, ListPlus, Play } from "lucide-react";
import {
ArrowLeft,
Check,
CheckCheck,
CheckCircle2,
Info,
Layers,
ListPlus,
Play,
RotateCcw,
Star,
type LucideIcon,
} from "lucide-react";
import {
api,
EMPTY_PLEX_FILTERS,
plexFilterCount,
type PlexCard,
type PlexCastMember,
type PlexFilters,
type PlexPerson,
type PlexSeasonDetail,
} from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
@ -22,6 +37,7 @@ const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
type Sub =
| { kind: "grid" }
| { kind: "show"; id: string }
| { kind: "season"; showId: string; seasonId: string }
| { kind: "player"; id: string; queue?: string[] }
| { kind: "info"; id: string }
| { kind: "playlist"; id: number };
@ -98,6 +114,24 @@ export default function PlexBrowse({
else if (sub.view.kind === "info" && infoScrollRef.current) el.scrollTop = infoScrollRef.current;
}, [sub.view.kind]);
// Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist),
// matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here;
// and never hijack Backspace while typing in a field.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Backspace") return;
const el = document.activeElement as HTMLElement | null;
const tag = (el?.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable) return;
if (["show", "season", "info", "playlist"].includes(sub.view.kind)) {
e.preventDefault();
sub.back();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [sub]);
const browseQ = useInfiniteQuery({
queryKey: ["plex-browse", library, dq, sort, show, filters],
enabled: !!library && sub.view.kind === "grid",
@ -217,11 +251,26 @@ export default function PlexBrowse({
);
}
if (sub.view.kind === "show") {
const showId = sub.view.id;
return (
<PlexShowView
showId={sub.view.id}
showId={showId}
library={library}
onBack={sub.back}
onPlay={(ep) => sub.open({ kind: "player", id: ep.id })}
onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })}
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
onOpenShow={(id) => sub.open({ kind: "show", id })}
/>
);
}
if (sub.view.kind === "season") {
const { showId, seasonId } = sub.view;
return (
<PlexSeasonView
showId={showId}
seasonId={seasonId}
onBack={sub.back}
onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })}
/>
);
}
@ -477,121 +526,430 @@ function PlexInfoView({
);
}
// --- Series drill-down: show detail page → season subpage → player -------------------------------
// Both the show page and the season page read the SAME cached ["plex-show", showId] payload (the
// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's
// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start),
// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only —
// Add the whole show to a collection.
function epLabel(ep?: PlexCard | null): string | undefined {
if (!ep) return undefined;
if (ep.season_number != null && ep.episode_number != null) return `S${ep.season_number} · E${ep.episode_number}`;
return ep.title;
}
function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
return (
<button
onClick={onBack}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
>
<ArrowLeft className="w-4 h-4" />
{label}
</button>
);
}
function ActionBtn({
onClick,
icon: Icon,
label,
sub,
primary,
disabled,
}: {
onClick: () => void;
icon: LucideIcon;
label: string;
sub?: string;
primary?: boolean;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
primary ? "bg-accent text-accent-fg shadow-sm hover:opacity-90" : "glass-card glass-hover"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex flex-col items-start leading-tight">
<span>{label}</span>
{sub && <span className="text-[11px] font-normal opacity-70">{sub}</span>}
</span>
</button>
);
}
function SeasonCard({ se, onOpen }: { se: PlexSeasonDetail; onOpen: () => void }) {
const { t } = useTranslation();
return (
<button onClick={onOpen} className="group text-left">
<div className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border">
<img
src={se.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{se.status === "watched" && (
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
{t("plex.watched")}
</span>
)}
{se.status === "in_progress" && (
<span className="absolute top-1.5 right-1.5 text-[10px] bg-accent/80 text-accent-fg px-1.5 py-0.5 rounded">
{t("plex.inProgress")}
</span>
)}
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
{t("plex.series.episodeCount", { count: se.episode_count })}
</span>
</div>
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{se.title}</div>
</button>
);
}
function CastStrip({ cast }: { cast: PlexCastMember[] }) {
const { t } = useTranslation();
return (
<section className="mb-8">
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
<div className="flex gap-4 overflow-x-auto pb-2">
{cast.map((c, i) => (
<div key={i} className="w-20 shrink-0 text-center">
<div className="mx-auto h-20 w-20 overflow-hidden rounded-full border border-border bg-surface">
{c.thumb ? (
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center opacity-40">{c.name.charAt(0)}</div>
)}
</div>
<div className="mt-1.5 text-xs font-medium line-clamp-2 leading-tight">{c.name}</div>
{c.role && <div className="text-[11px] text-muted line-clamp-1">{c.role}</div>}
</div>
))}
</div>
</section>
);
}
function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: string) => void }) {
const { t } = useTranslation();
return (
<section className="mb-8">
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<div className="flex gap-3 overflow-x-auto pb-2">
{related.map((r) => (
<button key={r.id} onClick={() => onOpen(r.id)} className="group w-[130px] shrink-0 text-left">
<div className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border">
<img
src={r.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{r.status === "watched" && (
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
{t("plex.watched")}
</span>
)}
</div>
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{r.title}</div>
</button>
))}
</div>
</section>
);
}
function EpisodeCard({ ep, onPlay, onAdd }: { ep: PlexCard; onPlay: () => void; onAdd: () => void }) {
const { t } = useTranslation();
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
const pct =
inProgress && ep.duration_seconds
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
: 0;
return (
<div className="group">
<button
onClick={onPlay}
className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left"
>
<img
src={ep.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
<Play className="w-9 h-9 text-white" fill="currentColor" />
</div>
{ep.status === "watched" && (
<span className="absolute top-1.5 right-1.5">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
</span>
)}
{pct > 0 && (
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</button>
<div className="mt-1.5 flex items-start gap-1.5">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium leading-tight">
<span className="text-muted mr-1">{ep.episode_number}.</span>
{ep.title}
</div>
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
</div>
<button
onClick={onAdd}
title={t("plex.playlist.addEpisode")}
aria-label={t("plex.playlist.addEpisode")}
className="shrink-0 rounded p-1 text-muted opacity-0 transition hover:text-accent group-hover:opacity-100"
>
<ListPlus className="w-4 h-4" />
</button>
</div>
</div>
);
}
function PlexShowView({
showId,
library,
onBack,
onPlay,
onOpenSeason,
onOpenShow,
}: {
showId: string;
library: string;
onBack: () => void;
onPlay: (epRk: string, queue: string[]) => void;
onOpenSeason: (seasonId: string) => void;
onOpenShow: (id: string) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(["me"])?.role === "admin";
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
const d = q.data;
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [collOpen, setCollOpen] = useState(false);
const [busy, setBusy] = useState(false);
const show = d?.show;
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
const watched = show?.status === "watched";
async function markAll(w: boolean) {
setBusy(true);
try {
await api.plexShowState(showId, w);
} finally {
setBusy(false);
}
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
qc.invalidateQueries({ queryKey: ["plex-browse"] });
}
return (
<div className="p-4 max-w-[1200px] mx-auto">
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
{q.isLoading || !d || !show ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : (
<>
{/* Hero */}
<div className="glass rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<img
src={show.thumb}
alt=""
className="w-32 sm:w-44 aspect-[2/3] object-cover rounded-xl border border-border shrink-0"
/>
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
{show.year && <span>{show.year}</span>}
{show.content_rating && <span>· {show.content_rating}</span>}
{show.season_count != null && <span>· {t("plex.seasons", { count: show.season_count })}</span>}
{show.imdb_rating != null && (
<span className="inline-flex items-center gap-1">
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{show.imdb_rating}
</span>
)}
</div>
{show.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{show.genres.map((g) => (
<span key={g} className="text-[11px] rounded-full bg-surface px-2 py-0.5 text-muted">
{g}
</span>
))}
</div>
)}
{show.summary && (
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">{show.summary}</p>
)}
<div className="mt-4 flex flex-wrap gap-2">
{show.resume && (
<ActionBtn
primary
onClick={() => onPlay(show.resume!.id, allKeys)}
icon={Play}
label={show.status === "new" ? t("plex.play") : t("plex.resume")}
sub={epLabel(show.resume)}
/>
)}
{show.first && show.status !== "new" && (
<ActionBtn onClick={() => onPlay(show.first!.id, allKeys)} icon={RotateCcw} label={t("plex.series.playFromStart")} />
)}
<ActionBtn
onClick={() => markAll(!watched)}
disabled={busy}
icon={watched ? CheckCheck : Check}
label={watched ? t("plex.series.markShowUnwatched") : t("plex.series.markShowWatched")}
/>
{allKeys.length > 0 && (
<ActionBtn
onClick={() => setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })}
icon={ListPlus}
label={t("plex.playlist.addShow")}
/>
)}
{isAdmin && library && (
<ActionBtn onClick={() => setCollOpen(true)} icon={Layers} label={t("plex.series.addShowCollection")} />
)}
</div>
</div>
</div>
{/* Seasons */}
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 mb-8">
{d.seasons.map((se) => (
<SeasonCard key={se.id} se={se} onOpen={() => onOpenSeason(se.id)} />
))}
</div>
{show.cast.length > 0 && <CastStrip cast={show.cast} />}
{d.related.length > 0 && <RelatedStrip related={d.related} onOpen={onOpenShow} />}
</>
)}
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
{collOpen && show && (
<PlexCollectionEditor
item={{ id: show.id, title: show.title }}
library={library}
memberOf={show.collection_keys}
onClose={() => setCollOpen(false)}
onChanged={() => qc.invalidateQueries({ queryKey: ["plex-show", showId] })}
/>
)}
</div>
);
}
function PlexSeasonView({
showId,
seasonId,
onBack,
onPlay,
}: {
showId: string;
seasonId: string;
onBack: () => void;
onPlay: (c: PlexCard) => void;
onPlay: (epRk: string, queue: string[]) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
const d = q.data;
// "Add to playlist" dialog target (single episode / whole season / whole show); null = closed.
const se = d?.seasons.find((s) => s.id === seasonId);
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const allEpisodeKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
const [busy, setBusy] = useState(false);
const seasonKeys = se?.episodes.map((e) => e.id) ?? [];
const watched = se?.status === "watched";
async function markAll(w: boolean) {
setBusy(true);
try {
await api.plexSeasonState(seasonId, w);
} finally {
setBusy(false);
}
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
qc.invalidateQueries({ queryKey: ["plex-browse"] });
}
return (
<div className="p-4 max-w-[1200px] mx-auto">
<button
onClick={onBack}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
>
<ArrowLeft className="w-4 h-4" />
{t("plex.backToLibrary")}
</button>
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
{q.isLoading || !d ? (
{q.isLoading || !d || !se ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : (
<>
<div className="flex gap-4 mb-6">
<div className="glass rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<img
src={d.show.thumb}
src={se.thumb}
alt=""
className="w-32 sm:w-40 aspect-[2/3] object-cover rounded-xl border border-border shrink-0"
className="w-28 sm:w-40 aspect-[2/3] object-cover rounded-xl border border-border shrink-0"
/>
<div className="min-w-0">
<h1 className="text-xl font-semibold">{d.show.title}</h1>
{d.show.year && <p className="text-sm text-muted">{d.show.year}</p>}
{d.show.summary && (
<p className="text-sm text-muted mt-2 line-clamp-6 leading-relaxed">{d.show.summary}</p>
)}
{allEpisodeKeys.length > 0 && (
<button
onClick={() => setAddTarget({ kind: "group", ratingKeys: allEpisodeKeys, title: d.show.title })}
className="glass-card glass-hover mt-3 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium"
>
<ListPlus className="w-4 h-4" />
{t("plex.playlist.addShow")}
</button>
)}
<div className="min-w-0 flex-1">
<p className="text-sm text-muted">{d.show.title}</p>
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
<p className="text-sm text-muted mt-1">{t("plex.series.episodeCount", { count: se.episode_count })}</p>
<div className="mt-4 flex flex-wrap gap-2">
{se.resume && (
<ActionBtn
primary
onClick={() => onPlay(se.resume!.id, seasonKeys)}
icon={Play}
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
sub={epLabel(se.resume)}
/>
)}
{se.first && se.status !== "new" && (
<ActionBtn onClick={() => onPlay(se.first!.id, seasonKeys)} icon={RotateCcw} label={t("plex.series.playFromStart")} />
)}
<ActionBtn
onClick={() => markAll(!watched)}
disabled={busy}
icon={watched ? CheckCheck : Check}
label={watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")}
/>
{seasonKeys.length > 0 && (
<ActionBtn
onClick={() =>
setAddTarget({ kind: "group", ratingKeys: seasonKeys, title: `${d.show.title}${se.title}` })
}
icon={ListPlus}
label={t("plex.playlist.addSeason")}
/>
)}
</div>
</div>
</div>
{d.seasons.map((se) => (
<div key={se.id} className="mb-6">
<div className="mb-2 flex items-center gap-2">
<h2 className="text-sm font-semibold">{se.title}</h2>
{se.episodes.length > 0 && (
<button
onClick={() =>
setAddTarget({
kind: "group",
ratingKeys: se.episodes.map((e) => e.id),
title: `${d.show.title}${se.title}`,
})
}
title={t("plex.playlist.addSeason")}
aria-label={t("plex.playlist.addSeason")}
className="rounded p-1 text-muted hover:text-accent"
>
<ListPlus className="w-4 h-4" />
</button>
)}
</div>
<div className="flex flex-col divide-y divide-border/60">
{se.episodes.map((ep) => {
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
return (
<div key={ep.id} className="flex items-center gap-3 py-2 group">
<button onClick={() => onPlay(ep)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
<div className="relative w-28 aspect-video rounded-lg overflow-hidden bg-card border border-border shrink-0">
<img src={ep.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
{ep.status === "watched" && (
<span className="absolute top-1 right-1 text-[9px] bg-black/70 text-white px-1 rounded">
</span>
)}
{inProgress && <div className="absolute bottom-0 inset-x-0 h-0.5 bg-accent" />}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
<span className="text-muted mr-1.5">{ep.episode_number}.</span>
{ep.title}
</div>
{ep.summary && (
<div className="text-xs text-muted line-clamp-2 mt-0.5">{ep.summary}</div>
)}
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
</div>
</button>
<button
onClick={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
title={t("plex.playlist.addEpisode")}
aria-label={t("plex.playlist.addEpisode")}
className="shrink-0 rounded p-1.5 text-muted opacity-0 transition hover:text-accent group-hover:opacity-100"
>
<ListPlus className="w-4 h-4" />
</button>
</div>
);
})}
</div>
</div>
))}
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{se.episodes.map((ep) => (
<EpisodeCard
key={ep.id}
ep={ep}
onPlay={() => onPlay(ep.id, seasonKeys)}
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
/>
))}
</div>
</>
)}

View file

@ -70,6 +70,18 @@
"markWatched": "Als gesehen markieren",
"markUnwatched": "Als ungesehen markieren",
"seasons": "{{count}} Staffeln",
"series": {
"seasons": "Staffeln",
"backToShow": "Zurück zur Serie",
"episodeCount": "{{count}} Folgen",
"playFromStart": "Von Anfang an",
"related": "Ähnliche Serien",
"markShowWatched": "Serie als gesehen",
"markShowUnwatched": "Serie als ungesehen",
"markSeasonWatched": "Staffel als gesehen",
"markSeasonUnwatched": "Staffel als ungesehen",
"addShowCollection": "Zur Sammlung"
},
"playerSoon": "Player kommt bald — „{{title}}“",
"people": {
"match": "Personen",

View file

@ -70,6 +70,18 @@
"markWatched": "Mark watched",
"markUnwatched": "Mark unwatched",
"seasons": "{{count}} seasons",
"series": {
"seasons": "Seasons",
"backToShow": "Back to show",
"episodeCount": "{{count}} episodes",
"playFromStart": "Play from start",
"related": "Related shows",
"markShowWatched": "Mark show watched",
"markShowUnwatched": "Mark show unwatched",
"markSeasonWatched": "Mark season watched",
"markSeasonUnwatched": "Mark season unwatched",
"addShowCollection": "Add to collection"
},
"playerSoon": "Player coming soon — “{{title}}”",
"people": {
"match": "People",

View file

@ -70,6 +70,18 @@
"markWatched": "Megnézettnek jelöl",
"markUnwatched": "Nem-nézettnek jelöl",
"seasons": "{{count}} évad",
"series": {
"seasons": "Évadok",
"backToShow": "Vissza a sorozathoz",
"episodeCount": "{{count}} rész",
"playFromStart": "Lejátszás az elejéről",
"related": "Kapcsolódó sorozatok",
"markShowWatched": "Egész sorozat megnézve",
"markShowUnwatched": "Sorozat jelölés visszavonása",
"markSeasonWatched": "Egész évad megnézve",
"markSeasonUnwatched": "Évad jelölés visszavonása",
"addShowCollection": "Kollekcióhoz adás"
},
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
"people": {
"match": "Személyek",

View file

@ -664,10 +664,14 @@ export interface PlexBrowseResult {
items: PlexCard[];
}
export interface PlexSeasonDetail {
id: string;
id: string; // season rating_key
season_number: number | null;
title: string;
thumb: string;
episode_count: number;
status: string; // aggregate: new | in_progress | watched
resume?: PlexCard | null; // on-deck episode (last in-progress, else first unwatched)
first?: PlexCard | null; // first episode (Play from the start)
episodes: PlexCard[];
}
export interface PlexShowDetail {
@ -678,8 +682,23 @@ export interface PlexShowDetail {
year?: number | null;
thumb: string;
art: string;
content_rating?: string | null;
rating?: number | null;
genres: string[];
studio?: string | null;
season_count?: number | null;
imdb_rating?: number | null;
imdb_id?: string | null;
imdb_url?: string | null;
cast: PlexCastMember[];
status: string; // aggregate across all episodes
resume?: PlexCard | null;
first?: PlexCard | null;
episode_count: number;
collection_keys: string[];
};
seasons: PlexSeasonDetail[];
related: PlexCard[];
}
export interface PlexMarker {
@ -1230,6 +1249,11 @@ export const api = {
req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }) }),
plexShow: (id: string): Promise<PlexShowDetail> =>
req(`/api/plex/show/${encodeURIComponent(id)}`),
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
plexSeasonState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
req(`/api/plex/season/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
plexItem: (id: string): Promise<PlexItemDetail> =>
req(`/api/plex/item/${encodeURIComponent(id)}`),
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {