feat(plex): S4 actor detail page — filmography endpoint + person subview

Backend: new GET /api/plex/person/{name} returns a cast/crew member's cached headshot plus
the movies (PlexItem, kind=movie) and shows (PlexShow) in the enabled libraries whose
cast_names OR directors include the name — the inverse of the cast → filter link. Introduce a
shared _content_libs(db) helper (enabled-library partition) and reuse it in people_cards;
S5 will extend it with the HU-overlay exclusion. Watch-state comes from PlexState / rollup
(reusing _movie_card / _show_card / state_map). DB-lane tests cover cast+director credits,
library scoping, the kind filter, unknown-name empties, and the partition helper.

Frontend: a new `person` history-subview. A cast-row click (PlexInfo cast row + the show
page's CastStrip) now opens the person's filmography page instead of filtering; the filter
gesture moves to a "Filter library by {name}" button on the page itself. PlexPersonView
mirrors PlexInfoView's fetch-wrapper + glass hero (headshot + name + credit counts) and
renders Movies / Shows as PlexPosterCard grids, with an optimistic per-card watch toggle on
the person cache. New plexPerson query key + api client + PlexPersonDetail type; HU/EN copy.
This commit is contained in:
2026-08-16 21:26:58 +02:00
parent 1d6c5f051e
commit 8b8c077a29
8 changed files with 396 additions and 20 deletions
+170 -10
View File
@@ -44,6 +44,7 @@ import {
type PlexCard,
type PlexCastMember,
type PlexFilters,
type PlexPersonDetail,
type PlexSeasonDetail,
type PlexUnifiedResult,
} from "../lib/api";
@@ -76,6 +77,7 @@ type Sub =
| { kind: "season"; showId: string; seasonId: string }
| { kind: "player"; id: string; queue?: string[] }
| { kind: "info"; id: string }
| { kind: "person"; name: string }
| { kind: "playlist"; id: number };
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
@@ -122,9 +124,7 @@ export default function PlexBrowse() {
useEffect(() => {
if (jumpTo != null) {
sub.open(
jumpTo.type === "show"
? { kind: "show", id: jumpTo.id }
: { kind: "info", id: jumpTo.id },
jumpTo.type === "show" ? { kind: "show", id: jumpTo.id } : { kind: "info", id: jumpTo.id },
);
setJumpTo(null);
}
@@ -160,7 +160,7 @@ export default function PlexBrowse() {
const tag = (el?.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
return;
if (["show", "season", "info", "playlist"].includes(sub.view.kind)) {
if (["show", "season", "info", "person", "playlist"].includes(sub.view.kind)) {
e.preventDefault();
sub.back();
}
@@ -235,6 +235,12 @@ export default function PlexBrowse() {
infoScrollRef.current = 0; // fresh info page starts at the top
sub.open({ kind: "info", id: card.id });
}
// Open a cast/crew member's filmography page (from an info/show cast row). Remember the info-page
// scroll so browser Back to a cast row lands where it was, mirroring applyFilter's restore.
function onOpenPerson(name: string) {
infoScrollRef.current = scroller()?.scrollTop ?? 0;
sub.open({ kind: "person", name });
}
// Optimistically flip a card's status in every cached library page — both the title grid (`items`)
// and the search "Episodes" section (`episodes`) — so the quick toggle reacts instantly and reliably
// BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe.
@@ -315,6 +321,19 @@ export default function PlexBrowse() {
onPlay={() => sub.open({ kind: "player", id: infoId })}
onPlayItem={(id) => sub.open({ kind: "player", id })}
onFilter={applyFilter}
onOpenPerson={onOpenPerson}
/>
);
}
if (sub.view.kind === "person") {
const personName = sub.view.name;
return (
<PlexPersonView
name={personName}
onBack={sub.back}
onOpenCard={onCard}
onInfoCard={onInfo}
onFilter={applyFilter}
/>
);
}
@@ -328,6 +347,7 @@ export default function PlexBrowse() {
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
onOpenShow={(id) => sub.open({ kind: "show", id })}
onFilter={applyFilter}
onOpenPerson={onOpenPerson}
/>
);
}
@@ -635,12 +655,14 @@ function PlexInfoView({
onPlay,
onPlayItem,
onFilter,
onOpenPerson,
}: {
id: string;
onBack: () => void;
onPlay: () => void;
onPlayItem: (id: string) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
onOpenPerson: (name: string) => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) });
@@ -670,6 +692,7 @@ function PlexInfoView({
onPlay={onPlay}
onPlayItem={onPlayItem}
onFilter={onFilter}
onOpenPerson={onOpenPerson}
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
// query), so q refetches automatically — a manual refetch here just double-fetched.
/>
@@ -679,6 +702,130 @@ function PlexInfoView({
);
}
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it
// shows the person's headshot + name, a "filter the library by them" action, and their titles as two
// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape.
function PlexPersonView({
name,
onBack,
onOpenCard,
onInfoCard,
onFilter,
}: {
name: string;
onBack: () => void;
onOpenCard: (card: PlexCard) => void;
onInfoCard: (card: PlexCard) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
// Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload
// (both the movies and shows arrays — an id matches only one), then persist + resync. Mirrors the
// grid's optimisticStatus on the person-detail shape; also invalidates the library so its grid agrees.
async function toggle(card: PlexCard) {
const next = card.status === "watched" ? "new" : "watched";
qc.setQueryData<PlexPersonDetail>(qk.plexPerson(name), (old) =>
old
? {
...old,
movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)),
}
: old,
);
try {
await api.plexSetState(card.id, next);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexPerson(name) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
const section = (titleKey: string, cards: PlexCard[]) =>
cards.length > 0 ? (
<section className="mb-8">
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{cards.map((c) => (
<PlexPosterCard
key={c.id}
card={c}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
/>
))}
</div>
</section>
) : null;
const d = q.data;
const empty = !!d && d.movies.length === 0 && d.shows.length === 0;
return (
<div className="w-[90%] max-w-[1600px] mx-auto">
<div className="p-4 pb-0">
<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"
>
<ArrowLeft className="w-4 h-4" />
{t("plex.backToLibrary")}
</button>
</div>
{q.isError && !d ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d ? (
<LoadingState label={t("plex.loading")} />
) : (
<div className="p-4">
{/* Hero: headshot + name + a "filter the library by this person" action. */}
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6 flex items-center gap-4">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
{d.photo ? (
<img src={d.photo} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center opacity-40">
<User className="h-8 w-8" />
</div>
)}
</div>
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold">{d.name}</h1>
<p className="mb-2 text-xs text-muted">
{t("plex.person.credits", {
movies: d.movies.length,
shows: d.shows.length,
})}
</p>
<button
onClick={() => onFilter({ actors: [d.name] })}
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
>
{t("plex.person.filterBy", { name: d.name })}
</button>
</div>
</section>
{empty ? (
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
) : (
<>
{section("plex.person.movies", d.movies)}
{section("plex.person.shows", d.shows)}
</>
)}
</div>
)}
</div>
);
}
// --- Series drill-down: show detail page → season subpage → player -------------------------------
// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the
// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's
@@ -832,11 +979,14 @@ function SeasonCard({
function CastStrip({
cast,
onFilter,
onOpenPerson,
}: {
cast: PlexCastMember[];
onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const clickable = !!(onOpenPerson || onFilter);
return (
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
@@ -848,7 +998,7 @@ function CastStrip({
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"}`}
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${clickable ? "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" />
@@ -859,18 +1009,24 @@ function CastStrip({
)}
</div>
<div
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${onFilter ? "group-hover/cast:text-accent" : ""}`}
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
>
{c.name}
</div>
{c.role && <div className="text-2xs text-muted line-clamp-1">{c.role}</div>}
</>
);
return onFilter ? (
return clickable ? (
<button
key={i}
onClick={() => onFilter({ actors: [c.name] })}
title={t("plex.info.filterActor", { name: c.name })}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
title={
onOpenPerson
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0 text-center"
>
{inner}
@@ -1110,6 +1266,7 @@ function PlexShowView({
onOpenSeason,
onOpenShow,
onFilter,
onOpenPerson,
}: {
showId: string;
onBack: () => void;
@@ -1117,6 +1274,7 @@ function PlexShowView({
onOpenSeason: (seasonId: string) => void;
onOpenShow: (id: string) => void;
onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -1316,7 +1474,9 @@ function PlexShowView({
</div>
</section>
{show.cast.length > 0 && showCast && <CastStrip cast={show.cast} onFilter={onFilter} />}
{show.cast.length > 0 && showCast && (
<CastStrip cast={show.cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
)}
{d.related.length > 0 && showRelated && (
<RelatedStrip related={d.related} onOpen={onOpenShow} />
)}
+15 -5
View File
@@ -31,6 +31,8 @@ type Props = {
// When provided, metadata (year/genre/director/cast/studio/rating) becomes clickable and sets
// the corresponding Plex filter (then the opener navigates to the filtered grid).
onFilter?: (patch: Partial<PlexFilters>) => void;
// When provided, a cast member's photo/name opens their filmography page instead of filtering.
onOpenPerson?: (name: string) => void;
// Play a sibling title from a collection strip (opens its player).
onPlayItem?: (id: string) => void;
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
@@ -44,6 +46,7 @@ export default function PlexInfo({
onClose,
onStateChange,
onFilter,
onOpenPerson,
onPlayItem,
library,
}: Props) {
@@ -341,12 +344,13 @@ export default function PlexInfo({
</h2>
<div className="flex gap-3 overflow-x-auto pb-2">
{cast.map((c, i) => {
const clickable = !!(onOpenPerson || onFilter);
const inner = (
<>
<div
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
} ${onFilter ? "group-hover/cast:border-accent" : ""}`}
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
>
{c.thumb ? (
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
@@ -358,7 +362,7 @@ export default function PlexInfo({
</div>
<div
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
onFilter ? "group-hover/cast:text-accent" : ""
clickable ? "group-hover/cast:text-accent" : ""
}`}
>
{c.name}
@@ -372,11 +376,17 @@ export default function PlexInfo({
)}
</>
);
return onFilter ? (
return clickable ? (
<button
key={i}
onClick={() => onFilter({ actors: [c.name] })}
title={t("plex.info.filterActor", { name: c.name })}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
title={
onOpenPerson
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0 text-center"
>
{inner}
+8
View File
@@ -203,6 +203,7 @@
"filterYear": "Show titles from {{year}}",
"filterRating": "Show titles rated {{n}}+",
"filterActor": "Show titles with {{name}}",
"viewPerson": "View {{name}}'s filmography",
"browseCollection": "Browse collection →"
},
"playable": {
@@ -245,5 +246,12 @@
"count_one": "{{count}} title",
"count_other": "{{count}} titles",
"remove": "Remove from filter"
},
"person": {
"credits": "{{movies}} movies · {{shows}} shows",
"filterBy": "Filter library by {{name}}",
"movies": "Movies ({{count}})",
"shows": "Shows ({{count}})",
"empty": "No titles found for this person."
}
}
+8
View File
@@ -203,6 +203,7 @@
"filterYear": "{{year}} évi címek",
"filterRating": "{{n}}+ pontos címek",
"filterActor": "Címek {{name}} szereplésével",
"viewPerson": "{{name}} filmográfiája",
"browseCollection": "Kollekció böngészése →"
},
"playable": {
@@ -245,5 +246,12 @@
"count_one": "{{count}} cím",
"count_other": "{{count}} cím",
"remove": "Eltávolítás a szűrőből"
},
"person": {
"credits": "{{movies}} film · {{shows}} sorozat",
"filterBy": "Szűrés erre: {{name}}",
"movies": "Filmek ({{count}})",
"shows": "Sorozatok ({{count}})",
"empty": "Nincs találat ehhez a személyhez."
}
}
+9
View File
@@ -181,6 +181,12 @@ export interface PlexPersonCard {
photo?: string | null; // proxied person-image url, or null (placeholder)
count: number; // in-scope movies/shows featuring them
}
export interface PlexPersonDetail {
name: string;
photo?: string | null; // cached headshot, or null (placeholder)
movies: PlexCard[]; // enabled-library movies whose cast OR directors include this person
shows: PlexCard[]; // enabled-library shows, same
}
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
export interface PlexFilters {
genres: string[];
@@ -414,6 +420,9 @@ export const plexApi = {
req(
`/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`,
),
// One cast/crew member's filmography: cached headshot + movies and shows (cast OR directors).
plexPerson: (name: string): Promise<PlexPersonDetail> =>
req(`/api/plex/person/${encodeURIComponent(name)}`),
plexSession: (
id: string,
start = 0,
+1
View File
@@ -102,4 +102,5 @@ export const qk = {
plexFacets: (...rest: unknown[]) => ["plex-facets", ...rest],
plexPeopleCards: (scope: string, actors: string[]) =>
["plex-people-cards", scope, actors] as const,
plexPerson: (name: string) => ["plex-person", name] as const,
};