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")
|
mc = self._get(f"/library/metadata/{rating_key}/children")
|
||||||
return mc.get("Metadata", []) or []
|
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]:
|
def collections(self, section_key: str) -> list[dict]:
|
||||||
"""All collections in a library section (title/summary/thumb/childCount/smart)."""
|
"""All collections in a library section (title/summary/thumb/childCount/smart)."""
|
||||||
mc = self._get(f"/library/sections/{section_key}/collections")
|
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"
|
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):
|
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
|
"""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."""
|
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] = {}
|
by_season: dict[int | None, list] = {}
|
||||||
|
all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup
|
||||||
for e in eps:
|
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 {
|
return {
|
||||||
"show": {
|
"show": {
|
||||||
"id": sh.rating_key,
|
"id": sh.rating_key,
|
||||||
|
|
@ -1043,20 +1079,71 @@ def show_detail(
|
||||||
"year": sh.year,
|
"year": sh.year,
|
||||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||||
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
|
"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": [
|
"seasons": [
|
||||||
{
|
_season_block(se, by_season.get(se.id, []))
|
||||||
"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, []),
|
|
||||||
}
|
|
||||||
for se in seasons
|
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:
|
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
|
"""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."""
|
are proxyable — this is not an open image proxy."""
|
||||||
|
|
@ -1695,6 +1782,81 @@ def item_state(
|
||||||
return {"status": status}
|
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")
|
@router.post("/stream/{rating_key}/session")
|
||||||
def stream_session(
|
def stream_session(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,33 @@
|
||||||
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 {
|
import {
|
||||||
api,
|
api,
|
||||||
EMPTY_PLEX_FILTERS,
|
EMPTY_PLEX_FILTERS,
|
||||||
plexFilterCount,
|
plexFilterCount,
|
||||||
type PlexCard,
|
type PlexCard,
|
||||||
|
type PlexCastMember,
|
||||||
type PlexFilters,
|
type PlexFilters,
|
||||||
type PlexPerson,
|
type PlexPerson,
|
||||||
|
type PlexSeasonDetail,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { useHistorySubview } from "../lib/history";
|
import { useHistorySubview } from "../lib/history";
|
||||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
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.
|
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||||
|
|
@ -22,6 +37,7 @@ const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
|
||||||
type Sub =
|
type Sub =
|
||||||
| { kind: "grid" }
|
| { kind: "grid" }
|
||||||
| { kind: "show"; id: string }
|
| { kind: "show"; id: string }
|
||||||
|
| { kind: "season"; showId: string; seasonId: string }
|
||||||
| { kind: "player"; id: string; queue?: string[] }
|
| { kind: "player"; id: string; queue?: string[] }
|
||||||
| { kind: "info"; id: string }
|
| { kind: "info"; id: string }
|
||||||
| { kind: "playlist"; id: number };
|
| { kind: "playlist"; id: number };
|
||||||
|
|
@ -98,6 +114,24 @@ export default function PlexBrowse({
|
||||||
else if (sub.view.kind === "info" && infoScrollRef.current) el.scrollTop = infoScrollRef.current;
|
else if (sub.view.kind === "info" && infoScrollRef.current) el.scrollTop = infoScrollRef.current;
|
||||||
}, [sub.view.kind]);
|
}, [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({
|
const browseQ = useInfiniteQuery({
|
||||||
queryKey: ["plex-browse", library, dq, sort, show, filters],
|
queryKey: ["plex-browse", library, dq, sort, show, filters],
|
||||||
enabled: !!library && sub.view.kind === "grid",
|
enabled: !!library && sub.view.kind === "grid",
|
||||||
|
|
@ -217,11 +251,26 @@ export default function PlexBrowse({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (sub.view.kind === "show") {
|
if (sub.view.kind === "show") {
|
||||||
|
const showId = sub.view.id;
|
||||||
return (
|
return (
|
||||||
<PlexShowView
|
<PlexShowView
|
||||||
showId={sub.view.id}
|
showId={showId}
|
||||||
|
library={library}
|
||||||
onBack={sub.back}
|
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({
|
function PlexShowView({
|
||||||
showId,
|
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,
|
onBack,
|
||||||
onPlay,
|
onPlay,
|
||||||
}: {
|
}: {
|
||||||
showId: string;
|
showId: string;
|
||||||
|
seasonId: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onPlay: (c: PlexCard) => void;
|
onPlay: (epRk: string, queue: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
|
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
|
||||||
const d = q.data;
|
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 [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 (
|
return (
|
||||||
<div className="p-4 max-w-[1200px] mx-auto">
|
<div className="p-4 max-w-[1200px] mx-auto">
|
||||||
<button
|
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
|
||||||
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>
|
|
||||||
|
|
||||||
{q.isLoading || !d ? (
|
{q.isLoading || !d || !se ? (
|
||||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
<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
|
<img
|
||||||
src={d.show.thumb}
|
src={se.thumb}
|
||||||
alt=""
|
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">
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-xl font-semibold">{d.show.title}</h1>
|
<p className="text-sm text-muted">{d.show.title}</p>
|
||||||
{d.show.year && <p className="text-sm text-muted">{d.show.year}</p>}
|
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
|
||||||
{d.show.summary && (
|
<p className="text-sm text-muted mt-1">{t("plex.series.episodeCount", { count: se.episode_count })}</p>
|
||||||
<p className="text-sm text-muted mt-2 line-clamp-6 leading-relaxed">{d.show.summary}</p>
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
)}
|
{se.resume && (
|
||||||
{allEpisodeKeys.length > 0 && (
|
<ActionBtn
|
||||||
<button
|
primary
|
||||||
onClick={() => setAddTarget({ kind: "group", ratingKeys: allEpisodeKeys, title: d.show.title })}
|
onClick={() => onPlay(se.resume!.id, seasonKeys)}
|
||||||
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"
|
icon={Play}
|
||||||
>
|
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||||
<ListPlus className="w-4 h-4" />
|
sub={epLabel(se.resume)}
|
||||||
{t("plex.playlist.addShow")}
|
/>
|
||||||
</button>
|
)}
|
||||||
)}
|
{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>
|
</div>
|
||||||
|
|
||||||
{d.seasons.map((se) => (
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
||||||
<div key={se.id} className="mb-6">
|
{se.episodes.map((ep) => (
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<EpisodeCard
|
||||||
<h2 className="text-sm font-semibold">{se.title}</h2>
|
key={ep.id}
|
||||||
{se.episodes.length > 0 && (
|
ep={ep}
|
||||||
<button
|
onPlay={() => onPlay(ep.id, seasonKeys)}
|
||||||
onClick={() =>
|
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
|
||||||
setAddTarget({
|
/>
|
||||||
kind: "group",
|
))}
|
||||||
ratingKeys: se.episodes.map((e) => e.id),
|
</div>
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,18 @@
|
||||||
"markWatched": "Als gesehen markieren",
|
"markWatched": "Als gesehen markieren",
|
||||||
"markUnwatched": "Als ungesehen markieren",
|
"markUnwatched": "Als ungesehen markieren",
|
||||||
"seasons": "{{count}} Staffeln",
|
"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}}“",
|
"playerSoon": "Player kommt bald — „{{title}}“",
|
||||||
"people": {
|
"people": {
|
||||||
"match": "Personen",
|
"match": "Personen",
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,18 @@
|
||||||
"markWatched": "Mark watched",
|
"markWatched": "Mark watched",
|
||||||
"markUnwatched": "Mark unwatched",
|
"markUnwatched": "Mark unwatched",
|
||||||
"seasons": "{{count}} seasons",
|
"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}}”",
|
"playerSoon": "Player coming soon — “{{title}}”",
|
||||||
"people": {
|
"people": {
|
||||||
"match": "People",
|
"match": "People",
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,18 @@
|
||||||
"markWatched": "Megnézettnek jelöl",
|
"markWatched": "Megnézettnek jelöl",
|
||||||
"markUnwatched": "Nem-nézettnek jelöl",
|
"markUnwatched": "Nem-nézettnek jelöl",
|
||||||
"seasons": "{{count}} évad",
|
"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}}”",
|
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
|
||||||
"people": {
|
"people": {
|
||||||
"match": "Személyek",
|
"match": "Személyek",
|
||||||
|
|
|
||||||
|
|
@ -664,10 +664,14 @@ export interface PlexBrowseResult {
|
||||||
items: PlexCard[];
|
items: PlexCard[];
|
||||||
}
|
}
|
||||||
export interface PlexSeasonDetail {
|
export interface PlexSeasonDetail {
|
||||||
id: string;
|
id: string; // season rating_key
|
||||||
season_number: number | null;
|
season_number: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
thumb: 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[];
|
episodes: PlexCard[];
|
||||||
}
|
}
|
||||||
export interface PlexShowDetail {
|
export interface PlexShowDetail {
|
||||||
|
|
@ -678,8 +682,23 @@ export interface PlexShowDetail {
|
||||||
year?: number | null;
|
year?: number | null;
|
||||||
thumb: string;
|
thumb: string;
|
||||||
art: 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[];
|
seasons: PlexSeasonDetail[];
|
||||||
|
related: PlexCard[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlexMarker {
|
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 }) }),
|
req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }) }),
|
||||||
plexShow: (id: string): Promise<PlexShowDetail> =>
|
plexShow: (id: string): Promise<PlexShowDetail> =>
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
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> =>
|
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||||
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
||||||
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {
|
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