From 280c62dda8fa7afa2557362d436e6552f7f871b8 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Mon, 6 Jul 2026 00:56:01 +0200
Subject: [PATCH] feat(plex): search cast & crew names + clickable person cards
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Backend (migration 0046_plex_people_search): a new people_text column (cast +
director names, de-duped) is folded into the generated search_vector at weight B,
so the top search box finds titles by an actor/director name and ranks them above
summary-only mentions. New /api/plex/people endpoint returns the cast/crew matching
the term (name prefix or word-start) with a film count + a headshot (pulled from one
representative film's live metadata; image bytes disk-cached).
Frontend: PlexBrowse shows matching people as virtual cards above the grid; clicking
one adds them to the actor/director filter (multi-value, from the C1 work) and clears
the search box so you land on exactly that person's films. Answers the 'why does
"drew" match Rambo?' confusion — it was matching the word in the synopsis; now names
are searchable. i18n en/hu/de.
⚠️ Prod needs a Plex re-sync after deploy to populate people_text (search_vector
regenerates automatically once the column has data).
---
VERSION | 2 +-
.../versions/0046_plex_people_search.py | 52 ++++++++++++++
backend/app/models.py | 4 ++
backend/app/plex/sync.py | 3 +
backend/app/routes/plex.py | 67 +++++++++++++++++++
frontend/src/App.tsx | 1 +
frontend/src/components/PlexBrowse.tsx | 59 +++++++++++++++-
frontend/src/i18n/locales/de/plex.json | 7 ++
frontend/src/i18n/locales/en/plex.json | 7 ++
frontend/src/i18n/locales/hu/plex.json | 7 ++
frontend/src/lib/api.ts | 8 +++
frontend/src/lib/releaseNotes.ts | 9 +++
12 files changed, 223 insertions(+), 3 deletions(-)
create mode 100644 backend/alembic/versions/0046_plex_people_search.py
diff --git a/VERSION b/VERSION
index e93b647..81566e4 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.26.0
\ No newline at end of file
+0.27.0
\ No newline at end of file
diff --git a/backend/alembic/versions/0046_plex_people_search.py b/backend/alembic/versions/0046_plex_people_search.py
new file mode 100644
index 0000000..e8854c9
--- /dev/null
+++ b/backend/alembic/versions/0046_plex_people_search.py
@@ -0,0 +1,52 @@
+"""Plex: make cast/crew names searchable
+
+Revision ID: 0046_plex_people_search
+Revises: 0045_plex_filter_meta
+Create Date: 2026-07-06
+
+Adds `people_text` (the item's cast + director names, space-joined) and folds it into the generated
+`search_vector` at weight B — between the title (A) and the summary (C). So the top search box now
+finds titles by an actor/director name and ranks those above films that merely mention the name in
+their synopsis. A generated column's expression can't be altered in place, so we drop + recreate it
+(and its GIN index). `people_text` is populated on the next Plex sync.
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0046_plex_people_search"
+down_revision: Union[str, None] = "0045_plex_filter_meta"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+_CFG = "public.unaccent_simple"
+_NEW_VECTOR = (
+ f"setweight(to_tsvector('{_CFG}', coalesce(title, '')), 'A') || "
+ f"setweight(to_tsvector('{_CFG}', coalesce(people_text, '')), 'B') || "
+ f"setweight(to_tsvector('{_CFG}', left(coalesce(summary, ''), 1000)), 'C')"
+)
+_OLD_VECTOR = (
+ f"setweight(to_tsvector('{_CFG}', coalesce(title, '')), 'A') || "
+ f"setweight(to_tsvector('{_CFG}', left(coalesce(summary, ''), 1000)), 'C')"
+)
+
+
+def _rebuild_vector(expr: str) -> None:
+ op.execute("DROP INDEX IF EXISTS ix_plex_items_search_vector")
+ op.execute("ALTER TABLE plex_items DROP COLUMN search_vector")
+ op.execute(
+ f"ALTER TABLE plex_items ADD COLUMN search_vector tsvector "
+ f"GENERATED ALWAYS AS ({expr}) STORED"
+ )
+ op.execute("CREATE INDEX ix_plex_items_search_vector ON plex_items USING gin (search_vector)")
+
+
+def upgrade() -> None:
+ op.add_column("plex_items", sa.Column("people_text", sa.Text(), nullable=True))
+ _rebuild_vector(_NEW_VECTOR)
+
+
+def downgrade() -> None:
+ _rebuild_vector(_OLD_VECTOR)
+ op.drop_column("plex_items", "people_text")
diff --git a/backend/app/models.py b/backend/app/models.py
index 8c87b80..23d50b9 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -1044,10 +1044,14 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
genres: Mapped[list | None] = mapped_column(JSONB)
directors: Mapped[list | None] = mapped_column(JSONB)
cast_names: Mapped[list | None] = mapped_column(JSONB)
+ # Cast + director names, space-joined — folded into search_vector (weight B) so the search box
+ # finds titles by a person's name (and ranks them above summary-only mentions).
+ people_text: Mapped[str | None] = mapped_column(Text)
search_vector: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
+ "setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
persisted=True,
),
diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py
index e682301..21a3fcf 100644
--- a/backend/app/plex/sync.py
+++ b/backend/app/plex/sync.py
@@ -183,6 +183,9 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
row.genres = _tags(meta, "Genre")
row.directors = _tags(meta, "Director")
row.cast_names = _tags(meta, "Role", limit=20)
+ # Searchable people blob (cast + directors, de-duped) → folded into search_vector at sync time.
+ people = list(dict.fromkeys((row.directors or []) + (row.cast_names or [])))
+ row.people_text = " ".join(people) or None
mf = _media_facts(meta)
row.file_path = mf.get("file_path")
row.part_key = mf.get("part_key")
diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py
index 2ed696f..a73bc9a 100644
--- a/backend/app/routes/plex.py
+++ b/backend/app/routes/plex.py
@@ -353,6 +353,73 @@ def facets(
}
+def _like_escape(s: str) -> str:
+ return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+
+
+@router.get("/people")
+def people(
+ q: str,
+ library: str,
+ user: User = Depends(current_user),
+ db: Session = Depends(get_db),
+) -> dict:
+ """Cast/crew whose name matches the search term — drives the virtual person cards shown above the
+ grid. Returns up to a few matches (most films first) with a count + a photo."""
+ term = (q or "").strip()
+ lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
+ if lib is None or lib.kind != "movie" or len(term) < 2:
+ return {"people": []}
+ esc = _like_escape(term)
+ pfx, word = f"{esc}%", f"% {esc}%" # name starts with the term, or any of its words does
+ rows = db.execute(
+ text(
+ "SELECT name, kind, count(*) c FROM ("
+ " SELECT jsonb_array_elements_text(cast_names) AS name, 'actor' AS kind FROM plex_items"
+ " WHERE library_id = :lib AND kind = 'movie'"
+ " UNION ALL"
+ " SELECT jsonb_array_elements_text(directors) AS name, 'director' AS kind FROM plex_items"
+ " WHERE library_id = :lib AND kind = 'movie'"
+ ") x WHERE name ILIKE :pfx OR name ILIKE :word "
+ "GROUP BY name, kind ORDER BY c DESC, name LIMIT 5"
+ ),
+ {"lib": lib.id, "pfx": pfx, "word": word},
+ ).all()
+ return {
+ "people": [
+ {"name": name, "kind": kind, "count": c, "photo": _person_photo(db, lib.id, name, kind)}
+ for name, kind, c in rows
+ ]
+ }
+
+
+def _person_photo(db: Session, lib_id: int, name: str, kind: str) -> str | None:
+ """A person's headshot from one representative film's live metadata (image bytes are disk-cached)."""
+ col = PlexItem.directors if kind == "director" else PlexItem.cast_names
+ rk = (
+ db.query(PlexItem.rating_key)
+ .filter(PlexItem.library_id == lib_id, col.contains([name]))
+ .limit(1)
+ .scalar()
+ )
+ if not rk:
+ return None
+ try:
+ with PlexClient(db) as plex:
+ meta = plex.metadata(rk) or {}
+ except (PlexError, PlexNotConfigured):
+ return None
+ for r in meta.get("Director" if kind == "director" else "Role") or []:
+ if r.get("tag") == name:
+ thumb = str(r.get("thumb") or "")
+ return (
+ f"/api/plex/person-image?u={quote(thumb, safe='')}"
+ if thumb.startswith(_PERSON_IMG_HOST)
+ else None
+ )
+ return None
+
+
@router.get("/show/{rating_key}")
def show_detail(
rating_key: str,
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0db6c3f..67401b3 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -818,6 +818,7 @@ export default function App() {
) : page === "plex" && meQuery.data!.plex_enabled ? (
setFilters({ ...filters, q: "" })}
library={plexLib}
show={plexShowFilter}
sort={plexSort}
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx
index 855662a..28832b1 100644
--- a/frontend/src/components/PlexBrowse.tsx
+++ b/frontend/src/components/PlexBrowse.tsx
@@ -2,7 +2,7 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, CheckCircle2, Info, Play } from "lucide-react";
-import { api, type PlexCard, type PlexFilters } from "../lib/api";
+import { api, type PlexCard, type PlexFilters, type PlexPerson } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
@@ -24,6 +24,7 @@ type Sub =
type Props = {
q: string;
+ onClearSearch: () => void;
library: string;
show: string;
sort: string;
@@ -40,7 +41,7 @@ function dur(n?: number | null): string {
return h ? `${h}h ${m}m` : `${m}m`;
}
-export default function PlexBrowse({ q, library, show, sort, filters, setFilters }: Props) {
+export default function PlexBrowse({ q, onClearSearch, library, show, sort, filters, setFilters }: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
const dq = useDebounced(q.trim(), 350);
@@ -82,6 +83,21 @@ export default function PlexBrowse({ q, library, show, sort, filters, setFilters
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
const total = browseQ.data?.pages[0]?.total ?? 0;
+ // Cast/crew whose name matches the current search → virtual cards above the grid; clicking one
+ // adds that person to the filter. Only meaningful for movie libraries (the endpoint returns [] else).
+ const peopleQ = useQuery({
+ queryKey: ["plex-people", library, dq],
+ queryFn: () => api.plexPeople(library, dq),
+ enabled: !!library && dq.length >= 2 && sub.view.kind === "grid",
+ });
+ const people = peopleQ.data?.people ?? [];
+ function addPerson(p: PlexPerson) {
+ const key = p.kind === "director" ? "directors" : "actors";
+ const cur = filters[key];
+ if (!cur.includes(p.name)) setFilters({ ...filters, [key]: [...cur, p.name] });
+ onClearSearch(); // switch from the name search to the person filter (clears the search box)
+ }
+
// Infinite scroll: auto-load the next page when the sentinel scrolls into view.
const sentinel = useRef(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ;
@@ -162,6 +178,45 @@ export default function PlexBrowse({ q, library, show, sort, filters, setFilters
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
+ {/* Virtual person cards for a name search — click to add them to the filter. */}
+ {people.length > 0 && (
+
+
{t("plex.people.match")}
+
+ {people.map((p) => {
+ const added = (p.kind === "director" ? filters.directors : filters.actors).includes(p.name);
+ return (
+
+ );
+ })}
+
+
+ )}
+
{browseQ.isLoading ? (
{t("plex.loading")}
) : items.length === 0 ? (
diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json
index a77e2c6..fc8f2db 100644
--- a/frontend/src/i18n/locales/de/plex.json
+++ b/frontend/src/i18n/locales/de/plex.json
@@ -49,6 +49,13 @@
"markUnwatched": "Als ungesehen markieren",
"seasons": "{{count}} Staffeln",
"playerSoon": "Player kommt bald — „{{title}}“",
+ "people": {
+ "match": "Personen",
+ "actor": "Schauspieler",
+ "director": "Regie",
+ "count": "{{count}} Titel",
+ "added": "hinzugefügt"
+ },
"player": {
"loading": "Wird geladen…",
"back": "Zurück",
diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json
index 79bd804..1c39cd3 100644
--- a/frontend/src/i18n/locales/en/plex.json
+++ b/frontend/src/i18n/locales/en/plex.json
@@ -49,6 +49,13 @@
"markUnwatched": "Mark unwatched",
"seasons": "{{count}} seasons",
"playerSoon": "Player coming soon — “{{title}}”",
+ "people": {
+ "match": "People",
+ "actor": "Actor",
+ "director": "Director",
+ "count": "{{count}} titles",
+ "added": "added"
+ },
"player": {
"loading": "Loading…",
"back": "Back",
diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json
index 5131f55..9075345 100644
--- a/frontend/src/i18n/locales/hu/plex.json
+++ b/frontend/src/i18n/locales/hu/plex.json
@@ -49,6 +49,13 @@
"markUnwatched": "Nem-nézettnek jelöl",
"seasons": "{{count}} évad",
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
+ "people": {
+ "match": "Személyek",
+ "actor": "Színész",
+ "director": "Rendező",
+ "count": "{{count}} cím",
+ "added": "hozzáadva"
+ },
"player": {
"loading": "Betöltés…",
"back": "Vissza",
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 016d93f..827f317 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -756,6 +756,12 @@ export function plexFilterCount(f: PlexFilters): number {
f.studios.length
);
}
+export interface PlexPerson {
+ name: string;
+ kind: "actor" | "director";
+ count: number;
+ photo?: string | null;
+}
export interface PlexFacets {
genres: { value: string; count: number }[];
content_ratings: { value: string; count: number }[];
@@ -1122,6 +1128,8 @@ export const api = {
},
plexFacets: (library: string): Promise =>
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
+ plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> =>
+ req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
plexShow: (id: string): Promise =>
req(`/api/plex/show/${encodeURIComponent(id)}`),
plexItem: (id: string): Promise =>
diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts
index 943c964..b89f8d9 100644
--- a/frontend/src/lib/releaseNotes.ts
+++ b/frontend/src/lib/releaseNotes.ts
@@ -14,6 +14,15 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
+ {
+ version: "0.27.0",
+ date: "2026-07-06",
+ summary: "Search Plex by cast & crew — find every film an actor or director is in, with clickable person cards.",
+ features: [
+ "Plex search: the top search box now matches cast and crew names, not just titles — search “drew” and Drew Barrymore's films come up first (ranked above films that merely mention the word).",
+ "Plex search: matching actors and directors appear as person cards (photo + how many titles) above the results — click one to filter the library to their films and add them to the sidebar filter.",
+ ],
+ },
{
version: "0.26.0",
date: "2026-07-05",