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:
parent
569d31235d
commit
11b7558c6c
7 changed files with 694 additions and 102 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,18 +1079,69 @@ 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": [
|
||||
{
|
||||
_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}",
|
||||
"episodes": by_season.get(se.id, []),
|
||||
"episode_count": roll["episode_count"],
|
||||
"status": roll["status"],
|
||||
"resume": roll["resume"],
|
||||
"first": roll["first"],
|
||||
"episodes": cards,
|
||||
}
|
||||
for se in seasons
|
||||
],
|
||||
|
||||
|
||||
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:
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
);
|
||||
}
|
||||
|
||||
function PlexShowView({
|
||||
showId,
|
||||
onBack,
|
||||
onPlay,
|
||||
}: {
|
||||
showId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (c: PlexCard) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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 [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||
const allEpisodeKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
|
||||
// --- 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 (
|
||||
<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")}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
{q.isLoading || !d ? (
|
||||
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>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-4 mb-6">
|
||||
{/* Hero */}
|
||||
<div className="glass rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
||||
<img
|
||||
src={d.show.thumb}
|
||||
src={show.thumb}
|
||||
alt=""
|
||||
className="w-32 sm:w-40 aspect-[2/3] object-cover rounded-xl border border-border shrink-0"
|
||||
className="w-32 sm:w-44 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>
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
{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) => (
|
||||
<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>
|
||||
<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} />}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{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] })}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
}
|
||||
|
||||
function PlexSeasonView({
|
||||
showId,
|
||||
seasonId,
|
||||
onBack,
|
||||
onPlay,
|
||||
}: {
|
||||
showId: string;
|
||||
seasonId: string;
|
||||
onBack: () => 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;
|
||||
const se = d?.seasons.find((s) => s.id === seasonId);
|
||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||
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">
|
||||
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
|
||||
|
||||
{q.isLoading || !d || !se ? (
|
||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="glass rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
||||
<img
|
||||
src={se.thumb}
|
||||
alt=""
|
||||
className="w-28 sm:w-40 aspect-[2/3] object-cover rounded-xl border border-border shrink-0"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue