diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 10733c9d..ba0b5d90 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -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") diff --git a/backend/tests/test_plex_person.py b/backend/tests/test_plex_person.py new file mode 100644 index 00000000..510cfc92 --- /dev/null +++ b/backend/tests/test_plex_person.py @@ -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 diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 1df36a0d..bf58932e 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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 ( + ); } @@ -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) => 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) => 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(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 ? ( +
+

{t(titleKey, { count: cards.length })}

+
+ {cards.map((c) => ( + onOpenCard(c)} + onInfo={() => onInfoCard(c)} + onToggleWatched={toggle} + /> + ))} +
+
+ ) : null; + + const d = q.data; + const empty = !!d && d.movies.length === 0 && d.shows.length === 0; + return ( +
+
+ +
+ {q.isError && !d ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d ? ( + + ) : ( +
+ {/* Hero: headshot + name + a "filter the library by this person" action. */} +
+
+ {d.photo ? ( + + ) : ( +
+ +
+ )} +
+
+

{d.name}

+

+ {t("plex.person.credits", { + movies: d.movies.length, + shows: d.shows.length, + })} +

+ +
+
+ {empty ? ( +

{t("plex.person.empty")}

+ ) : ( + <> + {section("plex.person.movies", d.movies)} + {section("plex.person.shows", d.shows)} + + )} +
+ )} +
+ ); +} + // --- 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) => void; + onOpenPerson?: (name: string) => void; }) { const { t } = useTranslation(); + const clickable = !!(onOpenPerson || onFilter); return (
{c.thumb ? ( @@ -859,18 +1009,24 @@ function CastStrip({ )}
{c.name}
{c.role &&
{c.role}
} ); - return onFilter ? ( + return clickable ? (
- {show.cast.length > 0 && showCast && } + {show.cast.length > 0 && showCast && ( + + )} {d.related.length > 0 && showRelated && ( )} diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 9440fdf4..d320bb85 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -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) => 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({
{cast.map((c, i) => { + const clickable = !!(onOpenPerson || onFilter); const inner = ( <>
{c.thumb ? ( @@ -358,7 +362,7 @@ export default function PlexInfo({
{c.name} @@ -372,11 +376,17 @@ export default function PlexInfo({ )} ); - return onFilter ? ( + return clickable ? (