merge: Plex cast/crew search + clickable person cards — v0.27.0
This commit is contained in:
commit
09f63da5c8
12 changed files with 223 additions and 3 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.26.0
|
0.27.0
|
||||||
52
backend/alembic/versions/0046_plex_people_search.py
Normal file
52
backend/alembic/versions/0046_plex_people_search.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -1044,10 +1044,14 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
genres: Mapped[list | None] = mapped_column(JSONB)
|
genres: Mapped[list | None] = mapped_column(JSONB)
|
||||||
directors: Mapped[list | None] = mapped_column(JSONB)
|
directors: Mapped[list | None] = mapped_column(JSONB)
|
||||||
cast_names: 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(
|
search_vector: Mapped[object | None] = mapped_column(
|
||||||
TSVECTOR,
|
TSVECTOR,
|
||||||
Computed(
|
Computed(
|
||||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
"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')",
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
|
||||||
persisted=True,
|
persisted=True,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,9 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
||||||
row.genres = _tags(meta, "Genre")
|
row.genres = _tags(meta, "Genre")
|
||||||
row.directors = _tags(meta, "Director")
|
row.directors = _tags(meta, "Director")
|
||||||
row.cast_names = _tags(meta, "Role", limit=20)
|
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)
|
mf = _media_facts(meta)
|
||||||
row.file_path = mf.get("file_path")
|
row.file_path = mf.get("file_path")
|
||||||
row.part_key = mf.get("part_key")
|
row.part_key = mf.get("part_key")
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
@router.get("/show/{rating_key}")
|
||||||
def show_detail(
|
def show_detail(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
|
|
|
||||||
|
|
@ -818,6 +818,7 @@ export default function App() {
|
||||||
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
||||||
<PlexBrowse
|
<PlexBrowse
|
||||||
q={filters.q}
|
q={filters.q}
|
||||||
|
onClearSearch={() => setFilters({ ...filters, q: "" })}
|
||||||
library={plexLib}
|
library={plexLib}
|
||||||
show={plexShowFilter}
|
show={plexShowFilter}
|
||||||
sort={plexSort}
|
sort={plexSort}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, CheckCircle2, Info, Play } from "lucide-react";
|
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 { useDebounced } from "../lib/useDebounced";
|
||||||
import { useHistorySubview } from "../lib/history";
|
import { useHistorySubview } from "../lib/history";
|
||||||
|
|
||||||
|
|
@ -24,6 +24,7 @@ type Sub =
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
q: string;
|
q: string;
|
||||||
|
onClearSearch: () => void;
|
||||||
library: string;
|
library: string;
|
||||||
show: string;
|
show: string;
|
||||||
sort: string;
|
sort: string;
|
||||||
|
|
@ -40,7 +41,7 @@ function dur(n?: number | null): string {
|
||||||
return h ? `${h}h ${m}m` : `${m}m`;
|
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 { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const dq = useDebounced(q.trim(), 350);
|
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 items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
const total = browseQ.data?.pages[0]?.total ?? 0;
|
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.
|
// Infinite scroll: auto-load the next page when the sentinel scrolls into view.
|
||||||
const sentinel = useRef<HTMLDivElement>(null);
|
const sentinel = useRef<HTMLDivElement>(null);
|
||||||
const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ;
|
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 })}
|
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Virtual person cards for a name search — click to add them to the filter. */}
|
||||||
|
{people.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-muted mb-2">{t("plex.people.match")}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{people.map((p) => {
|
||||||
|
const added = (p.kind === "director" ? filters.directors : filters.actors).includes(p.name);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${p.kind}:${p.name}`}
|
||||||
|
onClick={() => addPerson(p)}
|
||||||
|
disabled={added}
|
||||||
|
className={`flex items-center gap-2.5 rounded-xl border p-1.5 pr-3 text-left transition ${
|
||||||
|
added ? "border-accent bg-accent/10" : "border-border bg-card hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="h-11 w-11 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||||
|
{p.photo ? (
|
||||||
|
<img src={p.photo} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center text-sm opacity-40">
|
||||||
|
{p.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">{p.name}</div>
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
{t(`plex.people.${p.kind}`)} · {t("plex.people.count", { count: p.count })}
|
||||||
|
{added ? ` · ${t("plex.people.added")}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{browseQ.isLoading ? (
|
{browseQ.isLoading ? (
|
||||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@
|
||||||
"markUnwatched": "Als ungesehen markieren",
|
"markUnwatched": "Als ungesehen markieren",
|
||||||
"seasons": "{{count}} Staffeln",
|
"seasons": "{{count}} Staffeln",
|
||||||
"playerSoon": "Player kommt bald — „{{title}}“",
|
"playerSoon": "Player kommt bald — „{{title}}“",
|
||||||
|
"people": {
|
||||||
|
"match": "Personen",
|
||||||
|
"actor": "Schauspieler",
|
||||||
|
"director": "Regie",
|
||||||
|
"count": "{{count}} Titel",
|
||||||
|
"added": "hinzugefügt"
|
||||||
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"loading": "Wird geladen…",
|
"loading": "Wird geladen…",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@
|
||||||
"markUnwatched": "Mark unwatched",
|
"markUnwatched": "Mark unwatched",
|
||||||
"seasons": "{{count}} seasons",
|
"seasons": "{{count}} seasons",
|
||||||
"playerSoon": "Player coming soon — “{{title}}”",
|
"playerSoon": "Player coming soon — “{{title}}”",
|
||||||
|
"people": {
|
||||||
|
"match": "People",
|
||||||
|
"actor": "Actor",
|
||||||
|
"director": "Director",
|
||||||
|
"count": "{{count}} titles",
|
||||||
|
"added": "added"
|
||||||
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@
|
||||||
"markUnwatched": "Nem-nézettnek jelöl",
|
"markUnwatched": "Nem-nézettnek jelöl",
|
||||||
"seasons": "{{count}} évad",
|
"seasons": "{{count}} évad",
|
||||||
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
|
"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": {
|
"player": {
|
||||||
"loading": "Betöltés…",
|
"loading": "Betöltés…",
|
||||||
"back": "Vissza",
|
"back": "Vissza",
|
||||||
|
|
|
||||||
|
|
@ -756,6 +756,12 @@ export function plexFilterCount(f: PlexFilters): number {
|
||||||
f.studios.length
|
f.studios.length
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export interface PlexPerson {
|
||||||
|
name: string;
|
||||||
|
kind: "actor" | "director";
|
||||||
|
count: number;
|
||||||
|
photo?: string | null;
|
||||||
|
}
|
||||||
export interface PlexFacets {
|
export interface PlexFacets {
|
||||||
genres: { value: string; count: number }[];
|
genres: { value: string; count: number }[];
|
||||||
content_ratings: { value: string; count: number }[];
|
content_ratings: { value: string; count: number }[];
|
||||||
|
|
@ -1122,6 +1128,8 @@ export const api = {
|
||||||
},
|
},
|
||||||
plexFacets: (library: string): Promise<PlexFacets> =>
|
plexFacets: (library: string): Promise<PlexFacets> =>
|
||||||
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
|
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<PlexShowDetail> =>
|
plexShow: (id: string): Promise<PlexShowDetail> =>
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,15 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: 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",
|
version: "0.26.0",
|
||||||
date: "2026-07-05",
|
date: "2026-07-05",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue