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",