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
+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}