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:
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user