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
+71 -5
View File
@@ -1544,6 +1544,18 @@ def _rich_meta(meta: dict) -> dict:
}
def _content_libs(db: Session) -> tuple[list[int], list[int]]:
"""The enabled content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
source of the browsable-library scope shared by people_cards and the person filmography endpoint.
(S5 will extend this to exclude HU overlay libraries and fold in the browse/facets partition sites.)"""
libs = db.execute(
select(PlexLibrary).where(PlexLibrary.enabled.is_(True))
).scalars().all()
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
return movie_lib_ids, show_lib_ids
@router.get("/people/cards")
def people_cards(
names: str,
@@ -1558,11 +1570,7 @@ def people_cards(
wanted = _csv(names)[:16]
if not wanted:
return {"people": []}
libs = db.execute(
select(PlexLibrary).where(PlexLibrary.enabled.is_(True))
).scalars().all()
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
movie_lib_ids, show_lib_ids = _content_libs(db)
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
want_shows = scope in ("show", "both") and bool(show_lib_ids)
photos = {
@@ -1599,6 +1607,64 @@ def people_cards(
return {"people": out}
@router.get("/person/{name}")
def person_detail(
name: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""One cast/crew member's filmography: their cached headshot + the movies and shows (in the enabled
libraries) whose cast OR directors include this name — the inverse of the cast-name → library-filter
link. The name string is the identity key (Plex cast carries no stable per-person id). An unknown
name simply returns empty sections."""
photo = db.scalar(select(PlexPerson.photo_thumb).where(PlexPerson.name == name))
movie_lib_ids, show_lib_ids = _content_libs(db)
movies: list[dict] = []
if movie_lib_ids:
items = db.execute(
select(PlexItem)
.where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_lib_ids),
or_(
PlexItem.cast_names.contains([name]),
PlexItem.directors.contains([name]),
),
)
.order_by(PlexItem.year.desc().nullslast(), func.lower(PlexItem.title))
).scalars().all()
item_ids = [it.id for it in items]
states = {
s.item_id: s
for s in db.execute(
select(PlexState).where(
PlexState.user_id == user.id,
PlexState.item_id.in_(item_ids or [0]),
)
).scalars()
}
movies = [_movie_card(it, states.get(it.id)) for it in items]
shows: list[dict] = []
if show_lib_ids:
show_rows = db.execute(
select(PlexShow)
.where(
PlexShow.library_id.in_(show_lib_ids),
or_(
PlexShow.cast_names.contains([name]),
PlexShow.directors.contains([name]),
),
)
.order_by(PlexShow.year.desc().nullslast(), func.lower(PlexShow.title))
).scalars().all()
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
shows = [_show_card(sh, statuses.get(sh.id, "new")) for sh in show_rows]
return {"name": name, "photo": photo, "movies": movies, "shows": shows}
_STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt")
+114
View File
@@ -0,0 +1,114 @@
"""DB-backed regression (E5 S4): GET /api/plex/person/{name} returns a cast/crew member's
filmography — movies + shows in the ENABLED libraries whose cast OR directors include the name,
each carrying the caller's watch-state, plus the cached headshot. Also covers `_content_libs`
scoping (disabled libraries excluded) and the kind filter (episodes are not movies).
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import (
PlexItem,
PlexLibrary,
PlexPerson,
PlexShow,
PlexState,
User,
)
from app.routes.plex.browse import _content_libs
NAME = "Robert De Niro"
def _get(db, user, monkeypatch, path):
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep)
finally:
app.dependency_overrides.clear()
def test_person_detail_returns_cast_and_director_credits(db, monkeypatch):
movies_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
shows_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
off_lib = PlexLibrary(plex_key="3", title="Off", kind="movie", enabled=False)
db.add_all([movies_lib, shows_lib, off_lib])
db.flush()
heat = PlexItem(
rating_key="m1", library_id=movies_lib.id, kind="movie", title="Heat",
year=1995, cast_names=[NAME, "Al Pacino"],
)
# Director-only credit: the name is NOT in cast_names, only directors → the OR must still catch it.
directed = PlexItem(
rating_key="m2", library_id=movies_lib.id, kind="movie", title="A Bronx Tale",
year=1993, cast_names=["Chazz Palminteri"], directors=[NAME],
)
# Excluded: a movie in a DISABLED library.
hidden = PlexItem(
rating_key="m3", library_id=off_lib.id, kind="movie", title="Hidden",
year=2000, cast_names=[NAME],
)
# Excluded: an episode (kind != "movie") that happens to list the name.
episode = PlexItem(
rating_key="e1", library_id=movies_lib.id, kind="episode", title="An Episode",
year=2001, cast_names=[NAME],
)
show = PlexShow(rating_key="s1", library_id=shows_lib.id, title="A Show", cast_names=[NAME])
db.add_all([heat, directed, hidden, episode, show])
db.add(PlexPerson(name=NAME, photo_thumb="/img/deniro.jpg"))
db.flush()
user = User(email="person@example.com")
db.add(user)
db.flush()
db.add(PlexState(user_id=user.id, item_id=heat.id, status="watched"))
db.commit()
resp = _get(db, user, monkeypatch, f"/api/plex/person/{NAME.replace(' ', '%20')}")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["name"] == NAME
assert body["photo"] == "/img/deniro.jpg"
# Both the cast credit (Heat) and the director-only credit (A Bronx Tale), year desc; NOT the
# disabled-library movie and NOT the episode.
assert [m["title"] for m in body["movies"]] == ["Heat", "A Bronx Tale"]
assert body["movies"][0]["status"] == "watched" # from the caller's PlexState
assert body["movies"][1]["status"] == "new"
assert all(m["type"] == "movie" for m in body["movies"])
# The show credit.
assert [s["title"] for s in body["shows"]] == ["A Show"]
assert body["shows"][0]["type"] == "show"
def test_person_detail_unknown_name_is_empty(db, monkeypatch):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Solo", cast_names=["Nobody Else"]))
user = User(email="empty@example.com")
db.add(user)
db.commit()
resp = _get(db, user, monkeypatch, "/api/plex/person/Ghost%20Actor")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body == {"name": "Ghost Actor", "photo": None, "movies": [], "shows": []}
def test_content_libs_partitions_only_enabled(db):
db.add_all([
PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True),
PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True),
PlexLibrary(plex_key="3", title="OffMovie", kind="movie", enabled=False),
])
db.commit()
movie_ids, show_ids = _content_libs(db)
assert len(movie_ids) == 1 and len(show_ids) == 1 # the disabled movie lib is excluded
+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,
};