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
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue