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

@ -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> => {