feat(plex): unify movies + shows into one cross-library browser

Collapse the separate Movie/Show library sections into ONE unified library with a
shared search + shared filter sidebar and a Movies/Shows/Both scope selector.

Backend: new GET /api/plex/library — a cross-library UNION of movies (plex_items) and
shows (plex_shows) as one mixed, paginated, sorted feed, scoped movie|show|both, with
the shared filters (extracted into _apply_meta_filters, DRY), FTS search, and per-user
watch-state (a show's state = the aggregate of its episodes). On a search that also
matches episodes, matching episodes come back in a separate 'episodes' list (the grouped
'Episodes' section — Proposal 3). /facets is now scope-aware (merged across the scope's
libraries). /item and /show now return their library section key (for the admin
collection editor, since there's no single library prop in the unified view).

Frontend: PlexSidebar's library picker -> a scope selector (Both/Movies/Shows); facets +
browse follow the scope (App's plexLib repurposed to a validated scope, default both).
PlexBrowse uses the unified endpoint, renders a mixed Titles grid + an Episodes section
on search. Poster cards are generalized: a hover Play/Resume overlay on every card, a
clickable title (not just the poster), and a movie/show type tag. The quick watched
toggle is now optimistic so it reliably flips BOTH ways (fixes the movie card that could
mark but not un-mark). Cast/crew members and the show hero's meta (year/rating/genre/
content-rating) are clickable filters; clicking a person widens the scope to Both so the
result is a mixed movie+show feed. i18n plex.filter.scope*/plex.unified.* (en/hu/de).

Still pending from the polish list (next pass): season-card quick toggles (watched +
add-to-playlist), per-episode watched toggle, and the full glassy art-bg + hide-cast
customize menu on the series pages.
This commit is contained in:
npeter83 2026-07-11 00:15:49 +02:00
parent 11b7558c6c
commit 736db017e4
9 changed files with 579 additions and 269 deletions

View file

@ -246,7 +246,10 @@ export default function App() {
const channelsView: ChannelsView =
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
// Plex module filters (its own left-sidebar filter section) — per-account persisted.
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, "");
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both");
const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both";
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
@ -740,8 +743,8 @@ export default function App() {
{page === "plex" && meQuery.data!.plex_enabled && (
<Suspense fallback={null}>
<PlexSidebar
library={plexLib}
setLibrary={setPlexLib}
scope={plexScope}
setScope={setPlexScope}
show={plexShowFilter}
setShow={setPlexShowFilter}
sort={plexSort}
@ -829,7 +832,8 @@ export default function App() {
<PlexBrowse
q={plexQ}
onClearSearch={() => setPlexQ("")}
library={plexLib}
scope={plexScope}
setScope={setPlexScope}
show={plexShowFilter}
sort={plexSort}
filters={plexFilters}

View file

@ -1,17 +1,19 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import {
ArrowLeft,
Check,
CheckCheck,
CheckCircle2,
Film,
Info,
Layers,
ListPlus,
Play,
RotateCcw,
Star,
Tv2,
type LucideIcon,
} from "lucide-react";
import {
@ -21,8 +23,8 @@ import {
type PlexCard,
type PlexCastMember,
type PlexFilters,
type PlexPerson,
type PlexSeasonDetail,
type PlexUnifiedResult,
} from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
@ -51,7 +53,8 @@ type Sub =
type Props = {
q: string;
onClearSearch: () => void;
library: string;
scope: string; // movie | show | both (unified cross-library scope)
setScope: (v: string) => void;
show: string;
sort: string;
filters: PlexFilters;
@ -73,7 +76,8 @@ function dur(n?: number | null): string {
export default function PlexBrowse({
q,
onClearSearch,
library,
scope,
setScope,
show,
sort,
filters,
@ -133,12 +137,12 @@ export default function PlexBrowse({
}, [sub]);
const browseQ = useInfiniteQuery({
queryKey: ["plex-browse", library, dq, sort, show, filters],
enabled: !!library && sub.view.kind === "grid",
queryKey: ["plex-library", scope, dq, sort, show, filters],
enabled: !!scope && sub.view.kind === "grid",
initialPageParam: 0,
queryFn: ({ pageParam }) =>
api.plexBrowse({
library,
api.plexLibrary({
scope,
q: dq || undefined,
sort,
show,
@ -154,21 +158,8 @@ export default function PlexBrowse({
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
const total = browseQ.data?.pages[0]?.total ?? 0;
// Cast/crew whose name matches the current search → virtual cards above the grid; clicking one
// adds that person to the filter. Only meaningful for movie libraries (the endpoint returns [] else).
const peopleQ = useQuery({
queryKey: ["plex-people", library, dq],
queryFn: () => api.plexPeople(library, dq),
enabled: !!library && dq.length >= 2 && sub.view.kind === "grid",
});
const people = peopleQ.data?.people ?? [];
function addPerson(p: PlexPerson) {
const key = p.kind === "director" ? "directors" : "actors";
const cur = filters[key];
if (!cur.includes(p.name)) setFilters({ ...filters, [key]: [...cur, p.name] });
onClearSearch(); // switch from the name search to the person filter (clears the search box)
}
// Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
const episodes = browseQ.data?.pages[0]?.episodes ?? [];
// Infinite scroll: auto-load the next page when the sentinel scrolls into view.
const sentinel = useRef<HTMLDivElement>(null);
@ -197,8 +188,40 @@ export default function PlexBrowse({
sub.open({ kind: "info", id: card.id });
}
async function toggleWatched(card: PlexCard) {
await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched");
qc.invalidateQueries({ queryKey: ["plex-browse"] });
const next = card.status === "watched" ? "new" : "watched";
// Optimistically flip the card in every cached library page so the quick toggle reacts instantly
// and reliably BOTH ways (mark and un-mark), then reconcile with the server.
qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: ["plex-library"] }, (old) =>
old
? {
...old,
pages: old.pages.map((pg) => ({
...pg,
items: pg.items.map((it) => (it.id === card.id ? { ...it, status: next } : it)),
})),
}
: old,
);
await api.plexSetState(card.id, next).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-library"] });
}
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
// Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person
// (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed).
function applyFilter(patch: Partial<PlexFilters>) {
const merged = { ...filters } as unknown as Record<string, unknown>;
for (const [k, v] of Object.entries(patch)) {
const cur = merged[k];
if (Array.isArray(v) && Array.isArray(cur)) {
merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
} else {
merged[k] = v;
}
}
setFilters(merged as unknown as PlexFilters);
if (patch.actors?.length || patch.directors?.length) setScope("both");
infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to the info/show page
sub.open({ kind: "grid" });
}
if (sub.view.kind === "player") {
@ -225,28 +248,10 @@ export default function PlexBrowse({
return (
<PlexInfoView
id={infoId}
library={library}
onBack={sub.back}
onPlay={() => sub.open({ kind: "player", id: infoId })}
onPlayItem={(id) => sub.open({ kind: "player", id })}
onFilter={(patch) => {
// Clicking a metadata chip sets that filter and shows the filtered grid. Array filters
// (genres/people/studios) UNION with what's already set (so you can stack people); scalars
// replace. We push a fresh grid entry (not history.back) so browser Back returns to this
// info page instead of leaving the Plex module.
const merged = { ...filters } as unknown as Record<string, unknown>;
for (const [k, v] of Object.entries(patch)) {
const cur = merged[k];
if (Array.isArray(v) && Array.isArray(cur)) {
merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
} else {
merged[k] = v;
}
}
setFilters(merged as unknown as PlexFilters);
infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to this info page
sub.open({ kind: "grid" });
}}
onFilter={applyFilter}
/>
);
}
@ -255,11 +260,11 @@ export default function PlexBrowse({
return (
<PlexShowView
showId={showId}
library={library}
onBack={sub.back}
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 })}
onFilter={applyFilter}
/>
);
}
@ -281,48 +286,9 @@ export default function PlexBrowse({
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
</p>
{/* Virtual person cards for a name search — click to add them to the filter. */}
{people.length > 0 && (
<div className="mb-4">
<p className="text-[11px] uppercase tracking-wide text-muted mb-2">{t("plex.people.match")}</p>
<div className="flex flex-wrap gap-2">
{people.map((p) => {
const added = (p.kind === "director" ? filters.directors : filters.actors).includes(p.name);
return (
<button
key={`${p.kind}:${p.name}`}
onClick={() => addPerson(p)}
disabled={added}
className={`flex items-center gap-2.5 rounded-xl border p-1.5 pr-3 text-left transition ${
added ? "border-accent bg-accent/10" : "border-border bg-card hover:border-accent"
}`}
>
<div className="h-11 w-11 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
{p.photo ? (
<img src={p.photo} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center text-sm opacity-40">
{p.name.charAt(0)}
</div>
)}
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{p.name}</div>
<div className="text-xs text-muted">
{t(`plex.people.${p.kind}`)} · {t("plex.people.count", { count: p.count })}
{added ? ` · ${t("plex.people.added")}` : ""}
</div>
</div>
</button>
);
})}
</div>
</div>
)}
{browseQ.isLoading ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : items.length === 0 ? (
) : items.length === 0 && episodes.length === 0 ? (
dq && plexFilterCount(filters) > 0 ? (
// A search that comes up empty WHILE filters are active is usually the filters, not the
// query — say so and offer a one-click escape, instead of a bare "No matches".
@ -339,17 +305,38 @@ export default function PlexBrowse({
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
)
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{items.map((c) => (
<PlexPosterCard
key={c.id}
card={c}
onOpen={() => onCard(c)}
onInfo={() => onInfo(c)}
onToggleWatched={toggleWatched}
/>
))}
</div>
<>
{/* Titles grid — movies + shows mixed (visually tagged). */}
{items.length > 0 && (
<>
{episodes.length > 0 && (
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{items.map((c) => (
<PlexPosterCard
key={c.id}
card={c}
onOpen={() => onCard(c)}
onInfo={() => onInfo(c)}
onToggleWatched={toggleWatched}
/>
))}
</div>
</>
)}
{/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
{episodes.length > 0 && (
<section className="mt-8">
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{episodes.map((ep) => (
<EpisodeCard key={ep.id} ep={ep} withShowTitle onPlay={() => onCard(ep)} />
))}
</div>
</section>
)}
</>
)}
<div ref={sentinel} className="h-8" />
@ -407,16 +394,14 @@ function PlexPosterCard({
decoding="async"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{/* Hover affordance: what a click does (play / resume / open show). */}
{/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */}
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
{isPlayable ? (
<div className="flex flex-col items-center gap-1 text-white">
<Play className="w-10 h-10" fill="currentColor" />
<span className="text-xs font-medium">{inProgress ? t("plex.resume") : t("plex.play")}</span>
</div>
) : (
<span className="text-white text-xs font-medium">{t("plex.openShow")}</span>
)}
<div className="flex flex-col items-center gap-1 text-white">
<Play className="w-10 h-10" fill="currentColor" />
<span className="text-xs font-medium">
{card.type === "show" ? t("plex.openShow") : inProgress ? t("plex.resume") : t("plex.play")}
</span>
</div>
</div>
{/* Quick watched toggle (movies/episodes), on hover. */}
{isPlayable && (
@ -467,13 +452,25 @@ function PlexPosterCard({
title={t(`plex.playable.${card.playable}`)}
/>
)}
{/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
<span
className="absolute bottom-1.5 right-1.5 rounded bg-black/65 p-0.5 text-white/90"
title={t(card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie")}
>
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
</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>
)}
</div>
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{card.title}</div>
<div
onClick={onOpen}
className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
>
{card.title}
</div>
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
</div>
);
@ -481,14 +478,12 @@ function PlexPosterCard({
function PlexInfoView({
id,
library,
onBack,
onPlay,
onPlayItem,
onFilter,
}: {
id: string;
library: string;
onBack: () => void;
onPlay: () => void;
onPlayItem: (id: string) => void;
@ -514,7 +509,7 @@ function PlexInfoView({
<PlexInfo
detail={q.data}
variant="page"
library={library}
library={q.data.library ?? undefined}
onPlay={onPlay}
onPlayItem={onPlayItem}
onFilter={onFilter}
@ -539,6 +534,16 @@ function epLabel(ep?: PlexCard | null): string | undefined {
return ep.title;
}
// A metadata value that filters the unified grid when clicked (if onClick is wired), else plain text.
function Fil({ onClick, className, children }: { onClick?: () => void; className?: string; children: ReactNode }) {
if (!onClick) return <span className={className}>{children}</span>;
return (
<button onClick={onClick} className={`${className ?? ""} cursor-pointer hover:text-accent transition`}>
{children}
</button>
);
}
function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
return (
<button
@ -613,25 +618,43 @@ function SeasonCard({ se, onOpen }: { se: PlexSeasonDetail; onOpen: () => void }
);
}
function CastStrip({ cast }: { cast: PlexCastMember[] }) {
function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (patch: Partial<PlexFilters>) => void }) {
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>
)}
{cast.map((c, i) => {
const inner = (
<>
<div className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${onFilter ? "border-border group-hover/cast:border-accent" : "border-border"}`}>
{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 ${onFilter ? "group-hover/cast:text-accent" : ""}`}>
{c.name}
</div>
{c.role && <div className="text-[11px] text-muted line-clamp-1">{c.role}</div>}
</>
);
return onFilter ? (
<button
key={i}
onClick={() => onFilter({ actors: [c.name] })}
title={t("plex.info.filterActor", { name: c.name })}
className="group/cast w-20 shrink-0 text-center"
>
{inner}
</button>
) : (
<div key={i} className="w-20 shrink-0 text-center">
{inner}
</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>
);
@ -666,7 +689,17 @@ function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: s
);
}
function EpisodeCard({ ep, onPlay, onAdd }: { ep: PlexCard; onPlay: () => void; onAdd: () => void }) {
function EpisodeCard({
ep,
onPlay,
onAdd,
withShowTitle,
}: {
ep: PlexCard;
onPlay: () => void;
onAdd?: () => void;
withShowTitle?: boolean;
}) {
const { t } = useTranslation();
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
const pct =
@ -701,20 +734,30 @@ function EpisodeCard({ ep, onPlay, onAdd }: { ep: PlexCard; onPlay: () => void;
</button>
<div className="mt-1.5 flex items-start gap-1.5">
<div className="min-w-0 flex-1">
{withShowTitle && ep.show_title && (
<div className="text-[11px] text-muted truncate">
{ep.show_title}
{ep.season_number != null && ep.episode_number != null
? ` · S${ep.season_number}·E${ep.episode_number}`
: ""}
</div>
)}
<div className="text-sm font-medium leading-tight">
<span className="text-muted mr-1">{ep.episode_number}.</span>
{!withShowTitle && <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>
{onAdd && (
<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>
);
@ -722,18 +765,18 @@ function EpisodeCard({ ep, onPlay, onAdd }: { ep: PlexCard; onPlay: () => void;
function PlexShowView({
showId,
library,
onBack,
onPlay,
onOpenSeason,
onOpenShow,
onFilter,
}: {
showId: string;
library: string;
onBack: () => void;
onPlay: (epRk: string, queue: string[]) => void;
onOpenSeason: (seasonId: string) => void;
onOpenShow: (id: string) => void;
onFilter?: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@ -745,6 +788,7 @@ function PlexShowView({
const [busy, setBusy] = useState(false);
const show = d?.show;
const showLib = show?.library ?? undefined;
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
const watched = show?.status === "watched";
async function markAll(w: boolean) {
@ -755,7 +799,7 @@ function PlexShowView({
setBusy(false);
}
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
qc.invalidateQueries({ queryKey: ["plex-browse"] });
qc.invalidateQueries({ queryKey: ["plex-library"] });
}
return (
@ -776,22 +820,37 @@ function PlexShowView({
<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.year != null && (
<Fil onClick={onFilter ? () => onFilter({ yearMin: show.year, yearMax: show.year }) : undefined}>
{show.year}
</Fil>
)}
{show.content_rating && (
<Fil onClick={onFilter ? () => onFilter({ contentRatings: [show.content_rating!] }) : undefined}>
· {show.content_rating}
</Fil>
)}
{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">
<button
onClick={onFilter ? () => onFilter({ ratingMin: Math.floor(show.imdb_rating!) }) : undefined}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
>
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{show.imdb_rating}
</span>
</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">
<Fil
key={g}
className="text-[11px] rounded-full bg-surface px-2 py-0.5 text-muted"
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
</span>
</Fil>
))}
</div>
)}
@ -824,7 +883,7 @@ function PlexShowView({
label={t("plex.playlist.addShow")}
/>
)}
{isAdmin && library && (
{isAdmin && showLib && (
<ActionBtn onClick={() => setCollOpen(true)} icon={Layers} label={t("plex.series.addShowCollection")} />
)}
</div>
@ -839,16 +898,16 @@ function PlexShowView({
))}
</div>
{show.cast.length > 0 && <CastStrip cast={show.cast} />}
{show.cast.length > 0 && <CastStrip cast={show.cast} onFilter={onFilter} />}
{d.related.length > 0 && <RelatedStrip related={d.related} onOpen={onOpenShow} />}
</>
)}
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
{collOpen && show && (
{collOpen && show && showLib && (
<PlexCollectionEditor
item={{ id: show.id, title: show.title }}
library={library}
library={showLib}
memberOf={show.collection_keys}
onClose={() => setCollOpen(false)}
onChanged={() => qc.invalidateQueries({ queryKey: ["plex-show", showId] })}
@ -887,7 +946,7 @@ function PlexSeasonView({
setBusy(false);
}
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
qc.invalidateQueries({ queryKey: ["plex-browse"] });
qc.invalidateQueries({ queryKey: ["plex-library"] });
}
return (

View file

@ -143,7 +143,7 @@ export default function PlexInfo({
const setState = async (status: "new" | "watched") => {
await api.plexSetState(detail.id, status).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
qc.invalidateQueries({ queryKey: ["plex-browse"] });
qc.invalidateQueries({ queryKey: ["plex-library"] });
qc.invalidateQueries({ queryKey: ["plex-show"] });
onStateChange?.();
};

View file

@ -1,9 +1,8 @@
import { useEffect, useState, type ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the
// full metadata filter set (genre / rating / year / duration / added / content rating + the
@ -12,8 +11,8 @@ import { useDebounced } from "../lib/useDebounced";
// backend so the sidebar only offers what the library actually contains.
type Props = {
library: string;
setLibrary: (v: string) => void;
scope: string; // movie | show | both (unified cross-library scope)
setScope: (v: string) => void;
show: string;
setShow: (v: string) => void;
sort: string;
@ -39,8 +38,8 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
];
export default function PlexSidebar({
library,
setLibrary,
scope,
setScope,
show,
setShow,
sort,
@ -52,7 +51,6 @@ export default function PlexSidebar({
onToggleCollapse,
}: Props) {
const { t } = useTranslation();
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
const qc = useQueryClient();
@ -64,33 +62,17 @@ export default function PlexSidebar({
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
onOpenPlaylist(pl.id);
}
const libs = libsQ.data?.libraries ?? [];
const activeLib = libs.find((l) => l.key === library);
const isMovieLib = activeLib?.kind === "movie";
// Facet values (genres/ratings/bounds) for the current cross-library scope (movie|show|both).
const facetsQ = useQuery({
queryKey: ["plex-facets", library],
queryFn: () => api.plexFacets(library),
enabled: !!library, // facets now come for movie AND show libraries (0052)
queryKey: ["plex-facets", scope],
queryFn: () => api.plexFacets(scope),
enabled: !!scope,
});
const facets = facetsQ.data;
// Collections picker (searchable; the list is only fetched when none is active).
const [collSearch, setCollSearch] = useState("");
const collSearchDeb = useDebounced(collSearch.trim(), 300);
const collectionsQ = useQuery({
queryKey: ["plex-collections", library, collSearchDeb],
queryFn: () => api.plexCollections(library, collSearchDeb || undefined),
enabled: !!library && !filters.collection, // collections apply to shows too
});
const collections = collectionsQ.data?.collections ?? [];
// Default to the first library once loaded (or if the stored one vanished).
useEffect(() => {
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
}, [libs, library, setLibrary]);
const fCount = plexFilterCount(filters); // shows carry the same filters now (0052)
// (Collection filtering: the active-collection chip is set from an item info page's "Browse
// collection"; there's no per-library picker in the unified cross-library scope.)
const fCount = plexFilterCount(filters);
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "added" ? 1 : 0);
const anyActive = activeCount > 0;
@ -103,7 +85,8 @@ export default function PlexSidebar({
setSort("added");
};
const sorts = isMovieLib ? MOVIE_SORTS : SHOW_SORTS;
// Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime).
const sorts = scope === "movie" ? MOVIE_SORTS : SHOW_SORTS;
const durBucketKey = DURATION_BUCKETS.find(
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
)?.key;
@ -137,20 +120,19 @@ export default function PlexSidebar({
return (
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
{/* Library scope */}
<Section label={t("plex.filter.library")}>
<div className="flex flex-col gap-1">
{libs.map((l) => (
{/* Scope — one unified library, filtered to movies, shows, or both. */}
<Section label={t("plex.filter.scope")}>
<div className="flex gap-1">
{(["both", "movie", "show"] as const).map((s) => (
<button
key={l.key}
onClick={() => setLibrary(l.key)}
className={`inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition ${
l.key === library ? "bg-accent text-accent-fg" : "glass-card glass-hover"
key={s}
onClick={() => setScope(s)}
className={`inline-flex flex-1 items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-sm transition ${
s === scope ? "bg-accent text-accent-fg" : "glass-card glass-hover"
}`}
>
{l.kind === "movie" ? <Film className="w-4 h-4" /> : <Tv2 className="w-4 h-4" />}
<span className="flex-1 text-left truncate">{l.title}</span>
<span className="opacity-60 text-xs">{l.count.toLocaleString()}</span>
{s === "movie" ? <Film className="w-4 h-4" /> : s === "show" ? <Tv2 className="w-4 h-4" /> : <Layers className="w-4 h-4" />}
<span className="truncate">{t(`plex.filter.scopeOpt.${s}`)}</span>
</button>
))}
</div>
@ -266,42 +248,18 @@ export default function PlexSidebar({
</Section>
)}
{/* Collection — pick one from the searchable list (or the active one shows as a chip). */}
<Section label={t("plex.filter.collection")}>
{filters.collection ? (
{/* Collection active-collection chip only (set from an item's info page "Browse
collection"). The searchable picker is per-library, so it's omitted in the unified scope. */}
{filters.collection && (
<Section label={t("plex.filter.collection")}>
<div className="flex flex-wrap gap-1.5">
<RemovableChip
label={filters.collectionTitle || filters.collection}
onRemove={() => patch({ collection: null, collectionTitle: null })}
/>
</div>
) : (
<div>
<input
value={collSearch}
onChange={(e) => setCollSearch(e.target.value)}
placeholder={t("plex.filter.collectionSearch")}
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
/>
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
{collections.map((c) => (
<button
key={c.id}
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
>
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
<span className="flex-1 truncate">{c.title}</span>
<span className="text-xs text-muted">{c.child_count}</span>
</button>
))}
{collections.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("plex.filter.collectionEmpty")}</span>
)}
</div>
</div>
)}
</Section>
</Section>
)}
{/* IMDb rating min */}
<Section label={t("plex.filter.rating")}>
@ -369,7 +327,7 @@ export default function PlexSidebar({
</Section>
{/* Duration buckets — movie-only (a show has no single runtime) */}
{isMovieLib && (
{scope === "movie" && (
<Section label={t("plex.filter.duration")}>
<ChipRow>
<Chip

View file

@ -13,6 +13,12 @@
},
"filter": {
"library": "Bibliothek",
"scope": "Bibliothek",
"scopeOpt": {
"both": "Alle",
"movie": "Filme",
"show": "Serien"
},
"show": "Anzeige",
"showOpt": {
"all": "Alle",
@ -70,6 +76,10 @@
"markWatched": "Als gesehen markieren",
"markUnwatched": "Als ungesehen markieren",
"seasons": "{{count}} Staffeln",
"unified": {
"titles": "Titel",
"episodes": "Folgen"
},
"series": {
"seasons": "Staffeln",
"backToShow": "Zurück zur Serie",

View file

@ -13,6 +13,12 @@
},
"filter": {
"library": "Library",
"scope": "Library",
"scopeOpt": {
"both": "All",
"movie": "Movies",
"show": "Shows"
},
"show": "Show",
"showOpt": {
"all": "All",
@ -70,6 +76,10 @@
"markWatched": "Mark watched",
"markUnwatched": "Mark unwatched",
"seasons": "{{count}} seasons",
"unified": {
"titles": "Titles",
"episodes": "Episodes"
},
"series": {
"seasons": "Seasons",
"backToShow": "Back to show",

View file

@ -13,6 +13,12 @@
},
"filter": {
"library": "Könyvtár",
"scope": "Könyvtár",
"scopeOpt": {
"both": "Mind",
"movie": "Filmek",
"show": "Sorozatok"
},
"show": "Megjelenítés",
"showOpt": {
"all": "Mind",
@ -70,6 +76,10 @@
"markWatched": "Megnézettnek jelöl",
"markUnwatched": "Nem-nézettnek jelöl",
"seasons": "{{count}} évad",
"unified": {
"titles": "Címek",
"episodes": "Epizódok"
},
"series": {
"seasons": "Évadok",
"backToShow": "Vissza a sorozathoz",

View file

@ -663,6 +663,16 @@ export interface PlexBrowseResult {
limit: number;
items: PlexCard[];
}
// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that
// also matches episodes (the grouped "Episodes" section).
export interface PlexUnifiedResult {
scope: string; // movie | show | both
total: number;
offset: number;
limit: number;
items: PlexCard[];
episodes: PlexCard[];
}
export interface PlexSeasonDetail {
id: string; // season rating_key
season_number: number | null;
@ -682,6 +692,7 @@ export interface PlexShowDetail {
year?: number | null;
thumb: string;
art: string;
library?: string | null; // Plex section key (for the admin collection editor)
content_rating?: string | null;
rating?: number | null;
genres: string[];
@ -716,6 +727,7 @@ export interface PlexItemDetail {
playable: string; // direct | remux | transcode
thumb: string;
art: string;
library?: string | null; // Plex section key (for the admin collection editor)
cast: PlexCastMember[];
imdb_rating?: number | null;
imdb_id?: string | null;
@ -1164,16 +1176,16 @@ export const api = {
syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }),
plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> =>
req("/api/plex/libraries"),
plexBrowse: (p: {
library: string;
plexLibrary: (p: {
scope: string; // movie | show | both
q?: string;
sort?: string;
show?: string;
offset?: number;
limit?: number;
filters?: PlexFilters;
}): Promise<PlexBrowseResult> => {
const u = new URLSearchParams({ library: p.library });
}): Promise<PlexUnifiedResult> => {
const u = new URLSearchParams({ scope: p.scope });
if (p.q) u.set("q", p.q);
if (p.sort) u.set("sort", p.sort);
if (p.show && p.show !== "all") u.set("show", p.show);
@ -1196,10 +1208,10 @@ export const api = {
if (f.collection) u.set("collection", f.collection);
if (f.sortDir === "asc") u.set("sort_dir", "asc");
}
return req(`/api/plex/browse?${u.toString()}`);
return req(`/api/plex/library?${u.toString()}`);
},
plexFacets: (library: string): Promise<PlexFacets> =>
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
plexFacets: (scope: string): Promise<PlexFacets> =>
req(`/api/plex/facets?scope=${encodeURIComponent(scope)}`),
plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> =>
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>