From 11b7558c6cc23cd5b6201ecb311eeabca5b561bf Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 10 Jul 2026 22:08:04 +0200 Subject: [PATCH] feat(plex): Plex-web-style 3-level series view (Phase 2 of series view) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/app/plex/client.py | 12 + backend/app/routes/plex.py | 178 +++++++- frontend/src/components/PlexBrowse.tsx | 544 ++++++++++++++++++++----- frontend/src/i18n/locales/de/plex.json | 12 + frontend/src/i18n/locales/en/plex.json | 12 + frontend/src/i18n/locales/hu/plex.json | 12 + frontend/src/lib/api.ts | 26 +- 7 files changed, 694 insertions(+), 102 deletions(-) diff --git a/backend/app/plex/client.py b/backend/app/plex/client.py index 034d6d4..54fbb28 100644 --- a/backend/app/plex/client.py +++ b/backend/app/plex/client.py @@ -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") diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 59f700e..e90ef1c 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -250,6 +250,26 @@ def _show_status(total: int, watched: int, inprog: int) -> str: return "new" +def _rollup(cards: list[dict]) -> dict: + """From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the + last in-progress one, else the first unwatched), the first episode (for Play from the start), and + the episode count. Drives the show/season Resume + Play + mark-all buttons.""" + total = len(cards) + watched = sum(1 for c in cards if c.get("status") == "watched") + inprog = [c for c in cards if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"] + resume = None + if inprog: + resume = inprog[-1] # continue the latest-started episode + else: + resume = next((c for c in cards if c.get("status") != "watched"), None) + return { + "status": _show_status(total, watched, len(inprog)), + "resume": resume, + "first": cards[0] if cards else None, + "episode_count": total, + } + + def _show_agg_subq(db: Session, user_id: int): """Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show grid by aggregate watch-state and (b) compute each card's badge.""" @@ -1033,8 +1053,24 @@ def show_detail( ) } by_season: dict[int | None, list] = {} + all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup for e in eps: - by_season.setdefault(e.season_id, []).append(_episode_card(e, states.get(e.id))) + card = _episode_card(e, states.get(e.id)) + by_season.setdefault(e.season_id, []).append(card) + all_cards.append(card) + show_roll = _rollup(all_cards) + + # Cast + IMDb (best-effort live metadata; same shape as the movie/episode info page). + rich: dict = {} + related: list[dict] = [] + try: + with PlexClient(db) as plex: + meta = plex.metadata(sh.rating_key) or {} + rich = _rich_meta(meta) + related = _related_show_cards(db, user.id, plex.related(sh.rating_key), exclude=sh.id) + except (PlexError, PlexNotConfigured): + pass + return { "show": { "id": sh.rating_key, @@ -1043,20 +1079,71 @@ def show_detail( "year": sh.year, "thumb": f"/api/plex/image/{sh.rating_key}", "art": f"/api/plex/image/{sh.rating_key}?variant=art", + "content_rating": sh.content_rating, + "rating": sh.rating, + "genres": sh.genres or [], + "studio": sh.studio, + "season_count": sh.child_count, + "imdb_rating": rich.get("imdb_rating"), + "imdb_id": rich.get("imdb_id"), + "imdb_url": rich.get("imdb_url"), + "cast": rich.get("cast", []), + # Show-level rollup for the hero action buttons. + "status": show_roll["status"], + "resume": show_roll["resume"], + "first": show_roll["first"], + "episode_count": show_roll["episode_count"], + "collection_keys": sh.collection_keys or [], }, "seasons": [ - { - "id": se.rating_key, - "season_number": se.season_number, - "title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"), - "thumb": f"/api/plex/image/{se.rating_key}", - "episodes": by_season.get(se.id, []), - } + _season_block(se, by_season.get(se.id, [])) for se in seasons ], + "related": related, } +def _season_block(se: PlexSeason, cards: list[dict]) -> dict: + """A season's card for the show page (with its aggregate rollup) plus its episodes (used by the + season subpage, read from the same cached show-detail payload).""" + roll = _rollup(cards) + return { + "id": se.rating_key, + "season_number": se.season_number, + "title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"), + "thumb": f"/api/plex/image/{se.rating_key}", + "episode_count": roll["episode_count"], + "status": roll["status"], + "resume": roll["resume"], + "first": roll["first"], + "episodes": cards, + } + + +def _related_show_cards(db: Session, user_id: int, related: list[dict], exclude: int) -> list[dict]: + """Map Plex 'related' metadata to show cards for the shows we actually mirror (so they're openable), + excluding the current show. Order preserved; capped.""" + rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")] + if not rks: + return [] + shows = { + s.rating_key: s + for s in db.query(PlexShow).filter(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude) + } + statuses = _show_state_map(db, user_id, [s.id for s in shows.values()]) + out: list[dict] = [] + seen: set[str] = set() + for rk in rks: + sh = shows.get(rk) + if sh is None or rk in seen: + continue + seen.add(rk) + out.append(_show_card(sh, statuses.get(sh.id, "new"))) + if len(out) >= 20: + break + return out + + def _image_key(db: Session, rating_key: str, variant: str) -> str | None: """The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys are proxyable — this is not an open image proxy.""" @@ -1695,6 +1782,81 @@ def item_state( return {"status": status} +def _bulk_state( + background: BackgroundTasks, db: Session, user_id: int, eps: list[PlexItem], watched: bool +) -> int: + """Mark every episode in `eps` watched (or unwatched) for a user, mirroring each change to a + linked Plex account in the background (best-effort). Returns how many rows actually changed.""" + now = datetime.now(timezone.utc) + existing = { + s.item_id: s + for s in db.query(PlexState).filter( + PlexState.user_id == user_id, PlexState.item_id.in_([e.id for e in eps] or [0]) + ) + } + pushable = plex_watch.link_for_push(db, user_id) is not None + to_push: list[tuple[int, str, str]] = [] + for e in eps: + st = existing.get(e.id) + prev = st.status if st is not None else "new" + if watched: + if st is None: + st = PlexState(user_id=user_id, item_id=e.id) + db.add(st) + st.status = "watched" + st.watched_at = now + st.position_seconds = 0 + st.synced_to_plex = False + if prev != "watched": + to_push.append((e.id, e.rating_key, "watched")) + else: # → new: drop any state (but keep the user's private "hidden" marks) + if st is not None and prev != "hidden": + db.delete(st) + if prev != "new": + to_push.append((e.id, e.rating_key, "unwatched")) + db.commit() + if pushable: + for item_id, rk, action in to_push: + background.add_task(plex_watch.push_state_to_plex, user_id, item_id, rk, action, 0, 0) + return len(to_push) + + +@router.post("/show/{rating_key}/state") +def show_state( + rating_key: str, + payload: dict, + background: BackgroundTasks, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex.""" + sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first() + if sh is None: + raise HTTPException(status_code=404, detail="Unknown Plex show") + watched = bool(payload.get("watched")) + eps = db.query(PlexItem).filter_by(show_id=sh.id, kind="episode").all() + changed = _bulk_state(background, db, user.id, eps, watched) + return {"changed": changed, "watched": watched} + + +@router.post("/season/{rating_key}/state") +def season_state( + rating_key: str, + payload: dict, + background: BackgroundTasks, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex.""" + se = db.query(PlexSeason).filter_by(rating_key=str(rating_key)).first() + if se is None: + raise HTTPException(status_code=404, detail="Unknown Plex season") + watched = bool(payload.get("watched")) + eps = db.query(PlexItem).filter_by(season_id=se.id, kind="episode").all() + changed = _bulk_state(background, db, user.id, eps, watched) + return {"changed": changed, "watched": watched} + + @router.post("/stream/{rating_key}/session") def stream_session( rating_key: str, diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 397f57a..f859d0b 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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 ( 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 ( + 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 ( + + ); +} + +function ActionBtn({ + onClick, + icon: Icon, + label, + sub, + primary, + disabled, +}: { + onClick: () => void; + icon: LucideIcon; + label: string; + sub?: string; + primary?: boolean; + disabled?: boolean; +}) { + return ( + + ); +} + +function SeasonCard({ se, onOpen }: { se: PlexSeasonDetail; onOpen: () => void }) { + const { t } = useTranslation(); + return ( + + ); +} + +function CastStrip({ cast }: { cast: PlexCastMember[] }) { + const { t } = useTranslation(); + return ( +
+

{t("plex.info.cast")}

+
+ {cast.map((c, i) => ( +
+
+ {c.thumb ? ( + + ) : ( +
{c.name.charAt(0)}
+ )} +
+
{c.name}
+ {c.role &&
{c.role}
} +
+ ))} +
+
+ ); +} + +function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: string) => void }) { + const { t } = useTranslation(); + return ( +
+

{t("plex.series.related")}

+
+ {related.map((r) => ( + + ))} +
+
+ ); +} + +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 ( +
+ +
+
+
+ {ep.episode_number}. + {ep.title} +
+
{dur(ep.duration_seconds)}
+
+ +
+
+ ); +} + 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(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 ( +
+ + + {q.isLoading || !d || !show ? ( +

{t("plex.loading")}

+ ) : ( + <> + {/* Hero */} +
+ +
+

{show.title}

+
+ {show.year && {show.year}} + {show.content_rating && · {show.content_rating}} + {show.season_count != null && · {t("plex.seasons", { count: show.season_count })}} + {show.imdb_rating != null && ( + + · + {show.imdb_rating} + + )} +
+ {show.genres.length > 0 && ( +
+ {show.genres.map((g) => ( + + {g} + + ))} +
+ )} + {show.summary && ( +

{show.summary}

+ )} +
+ {show.resume && ( + 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" && ( + onPlay(show.first!.id, allKeys)} icon={RotateCcw} label={t("plex.series.playFromStart")} /> + )} + markAll(!watched)} + disabled={busy} + icon={watched ? CheckCheck : Check} + label={watched ? t("plex.series.markShowUnwatched") : t("plex.series.markShowWatched")} + /> + {allKeys.length > 0 && ( + setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })} + icon={ListPlus} + label={t("plex.playlist.addShow")} + /> + )} + {isAdmin && library && ( + setCollOpen(true)} icon={Layers} label={t("plex.series.addShowCollection")} /> + )} +
+
+
+ + {/* Seasons */} +

{t("plex.series.seasons")}

+
+ {d.seasons.map((se) => ( + onOpenSeason(se.id)} /> + ))} +
+ + {show.cast.length > 0 && } + {d.related.length > 0 && } + + )} + + {addTarget && setAddTarget(null)} />} + {collOpen && show && ( + setCollOpen(false)} + onChanged={() => qc.invalidateQueries({ queryKey: ["plex-show", showId] })} + /> + )} +
+ ); +} + +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(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 (
- + - {q.isLoading || !d ? ( + {q.isLoading || !d || !se ? (

{t("plex.loading")}

) : ( <> -
+
-
-

{d.show.title}

- {d.show.year &&

{d.show.year}

} - {d.show.summary && ( -

{d.show.summary}

- )} - {allEpisodeKeys.length > 0 && ( - - )} +
+

{d.show.title}

+

{se.title}

+

{t("plex.series.episodeCount", { count: se.episode_count })}

+
+ {se.resume && ( + 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" && ( + onPlay(se.first!.id, seasonKeys)} icon={RotateCcw} label={t("plex.series.playFromStart")} /> + )} + markAll(!watched)} + disabled={busy} + icon={watched ? CheckCheck : Check} + label={watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")} + /> + {seasonKeys.length > 0 && ( + + setAddTarget({ kind: "group", ratingKeys: seasonKeys, title: `${d.show.title} — ${se.title}` }) + } + icon={ListPlus} + label={t("plex.playlist.addSeason")} + /> + )} +
- {d.seasons.map((se) => ( -
-
-

{se.title}

- {se.episodes.length > 0 && ( - - )} -
-
- {se.episodes.map((ep) => { - const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; - return ( -
- - -
- ); - })} -
-
- ))} +
+ {se.episodes.map((ep) => ( + onPlay(ep.id, seasonKeys)} + onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })} + /> + ))} +
)} diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index d3145c6..0debbf7 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -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", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 2aef3b5..71d32d6 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 9df0b51..4dc8f63 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -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", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ab645b3..93b2dac 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 => 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 => req(`/api/plex/item/${encodeURIComponent(id)}`), plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise => {