feat(plex): search cast & crew names + clickable person cards
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).
This commit is contained in:
parent
6c09420068
commit
280c62dda8
12 changed files with 223 additions and 3 deletions
|
|
@ -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<HTMLDivElement>(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 })}
|
||||
</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 ? (
|
||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue