E5 S6: enriched person page (TMDB bio + birth facts + Known-for rail)
The person page now leads with a TMDB bio, lazily fetched the first time it's
opened (off the S2-linked `tmdb_person_id`) and then served from the row:
- Migration 0072: ADD-only nullable person columns on plex_people — biography,
birthday, deathday, place_of_birth, known_for_department, also_known_as,
known_for ([{tmdb_id, media_type, title}]), tmdb_enriched_at.
- tmdb.py: TmdbClient.fetch_person (/person/{id}+combined_credits), parse_person
(bio + a popularity-ranked, deduped known_for), apply_person, enrich_person
(lazy one-shot, key+id guarded, commits, 404 stamps the watermark).
- people.py person_detail: loads the full person row, lazily enriches (best-effort
— never fails the page), serves the bio fields + resolves known_for to library
rows by guid (in-library only, popularity order).
- FE PlexPersonView: bio/birth-facts/aka hero + a "Known for" Carousel rail above
the existing Movies/Shows filmography; PlexPersonDetail type + i18n (en+hu).
Tests: test_plex_person_bio.py (6: parse / lazy-enrich guards / serve) + extended
PlexPersonView.dom.test.tsx + the unknown-name contract in test_plex_person.py.
Browser-E2E'd on Jim Carrey (live TMDB enrich → bio 1937 chars, born 1962-01-17
Newmarket, Known-for resolved). Gate: vitest 337, backend lane 402.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""plex person TMDB bio columns (E5 S6)
|
||||
|
||||
Additive TMDB-sourced person fields on plex_people — biography, birth/death dates, place of birth,
|
||||
known-for department, also-known-as, and a `known_for` credit pool ([{tmdb_id, media_type, title}]
|
||||
resolved to library rows by guid at serve time) — plus a `tmdb_enriched_at` watermark. Filled the
|
||||
first time a person page is opened (from /person/{tmdb_person_id}), then SERVED FROM THE ROW. The
|
||||
`tmdb_person_id` link itself already exists (0071). ADD-only, all nullable → no table rewrite, no
|
||||
ACCESS EXCLUSIVE lock. `tmdb_enriched_at` starts NULL, so every person is enriched once on first view.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0072_plex_person_bio"
|
||||
down_revision: Union[str, None] = "0071_plex_tmdb_fields"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_COLS: tuple[tuple[str, sa.types.TypeEngine], ...] = (
|
||||
("biography", sa.Text()),
|
||||
("birthday", sa.String(length=10)),
|
||||
("deathday", sa.String(length=10)),
|
||||
("place_of_birth", sa.Text()),
|
||||
("known_for_department", sa.String(length=64)),
|
||||
("also_known_as", postgresql.JSONB()),
|
||||
("known_for", postgresql.JSONB()),
|
||||
("tmdb_enriched_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for name, type_ in _COLS:
|
||||
op.add_column("plex_people", sa.Column(name, type_, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for name, _ in reversed(_COLS):
|
||||
op.drop_column("plex_people", name)
|
||||
@@ -1259,6 +1259,18 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
|
||||
# TMDB person id, linked by name from a title's TMDB credits (E5 S2 lays the foundation; the /person
|
||||
# biography fetch + person-page render land in S6). NULL until a TMDB-enriched title names this person.
|
||||
tmdb_person_id: Mapped[int | None] = mapped_column(Integer)
|
||||
# --- TMDB person enrichment (E5 S6): fetched from /person/{tmdb_person_id} the first time the person
|
||||
# page is opened, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a
|
||||
# no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id,
|
||||
# media_type, title}], resolved to library rows by guid at serve time (like recommendations). ---
|
||||
biography: Mapped[str | None] = mapped_column(Text)
|
||||
birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is
|
||||
deathday: Mapped[str | None] = mapped_column(String(10))
|
||||
place_of_birth: Mapped[str | None] = mapped_column(Text)
|
||||
known_for_department: Mapped[str | None] = mapped_column(String(64))
|
||||
also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
known_for: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [{tmdb_id, media_type, title}]
|
||||
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
|
||||
|
||||
@@ -119,6 +119,10 @@ class TmdbClient:
|
||||
"""Resolve an external id (imdb_id / tvdb_id) to TMDB items via /find."""
|
||||
return self._get(f"/find/{external_id}", {"external_source": source})
|
||||
|
||||
def fetch_person(self, person_id: int) -> dict | None:
|
||||
"""One person's bio + combined credits (E5 S6), in a single append_to_response request."""
|
||||
return self._get(f"/person/{person_id}", {"append_to_response": "combined_credits"})
|
||||
|
||||
|
||||
def guid_to_tmdb_id(guid: str | None, media_type: str, tmdb: TmdbClient) -> int | None:
|
||||
"""Map a stored canonical guid to a TMDB id. `tmdb:` is direct; `imdb:`/`tvdb:` go through /find
|
||||
@@ -377,3 +381,67 @@ def serve_block(db: Session, row) -> dict | None:
|
||||
"recommendations": resolve_recs(db, row.recommendations),
|
||||
"backdrops": row.backdrops or [],
|
||||
}
|
||||
|
||||
|
||||
# --- Person enrichment (E5 S6) -------------------------------------------------------------------
|
||||
# A person's bio + birth facts + a "Known for" credit pool, fetched once (the first person-page open)
|
||||
# and served from the plex_people row. `known_for` stores the top combined-credits ids; the person
|
||||
# endpoint resolves them to library rows by guid at serve time (like recommendations).
|
||||
|
||||
_KNOWN_FOR_MAX = 15
|
||||
|
||||
|
||||
def parse_person(payload: dict) -> dict:
|
||||
"""TMDB /person/{id} (+combined_credits) → the persisted bio fields. `known_for` = the person's
|
||||
most-popular movie/tv credits (deduped by id), top `_KNOWN_FOR_MAX`, resolved to library later."""
|
||||
credits = (payload.get("combined_credits") or {}).get("cast") or []
|
||||
seen: set[int] = set()
|
||||
known: list[dict] = []
|
||||
for c in sorted(credits, key=lambda c: c.get("popularity") or 0, reverse=True):
|
||||
tid, mt = c.get("id"), c.get("media_type")
|
||||
if not tid or mt not in ("movie", "tv") or tid in seen:
|
||||
continue
|
||||
seen.add(tid)
|
||||
known.append({"tmdb_id": tid, "media_type": mt, "title": c.get("title") or c.get("name")})
|
||||
if len(known) >= _KNOWN_FOR_MAX:
|
||||
break
|
||||
return {
|
||||
"biography": (payload.get("biography") or "").strip() or None,
|
||||
"birthday": payload.get("birthday") or None,
|
||||
"deathday": payload.get("deathday") or None,
|
||||
"place_of_birth": payload.get("place_of_birth") or None,
|
||||
"known_for_department": payload.get("known_for_department") or None,
|
||||
"also_known_as": payload.get("also_known_as") or None,
|
||||
"known_for": known or None,
|
||||
}
|
||||
|
||||
|
||||
def apply_person(row: PlexPerson, parsed: dict) -> None:
|
||||
row.biography = parsed["biography"]
|
||||
row.birthday = parsed["birthday"]
|
||||
row.deathday = parsed["deathday"]
|
||||
row.place_of_birth = parsed["place_of_birth"]
|
||||
row.known_for_department = parsed["known_for_department"]
|
||||
row.also_known_as = parsed["also_known_as"]
|
||||
row.known_for = parsed["known_for"]
|
||||
row.tmdb_enriched_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def enrich_person(db: Session, person: PlexPerson) -> bool:
|
||||
"""Lazily fetch + persist one person's TMDB bio (first person-page open), committing the write so it
|
||||
survives regardless of the read path. No-op (False) without a key or a `tmdb_person_id`; a transient
|
||||
error leaves `tmdb_enriched_at` NULL so the next open retries. A 404 (deleted person) stamps the
|
||||
watermark with empty fields so we don't refetch it every open."""
|
||||
if not person.tmdb_person_id or not sysconfig.get_str(db, "tmdb_api_key"):
|
||||
return False
|
||||
try:
|
||||
with TmdbClient(db) as tmdb:
|
||||
payload = tmdb.fetch_person(person.tmdb_person_id)
|
||||
except TmdbError:
|
||||
return False
|
||||
if payload is None:
|
||||
person.tmdb_enriched_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
apply_person(person, parse_person(payload))
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Plex people: cast/crew filter-chip cards + a single person's filmography page."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -14,11 +16,65 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
from app.plex.cards import movie_card, show_card
|
||||
from app.plex.query import split_csv
|
||||
from app.routes.plex._shared import _content_libs
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
log = logging.getLogger("siftlode.plex")
|
||||
|
||||
|
||||
def _resolve_known_for(
|
||||
db: Session,
|
||||
known_for: list | None,
|
||||
uid: int,
|
||||
movie_lib_ids: list[int],
|
||||
show_lib_ids: list[int],
|
||||
) -> list[dict]:
|
||||
"""The person's TMDB "Known for" credits (top by popularity) that are IN our library, as cards, in
|
||||
popularity order. Matched by the canonical guid `tmdb:<id>`; out-of-library credits are dropped."""
|
||||
if not known_for:
|
||||
return []
|
||||
guids = [f"tmdb:{k['tmdb_id']}" for k in known_for if k.get("tmdb_id")]
|
||||
if not guids:
|
||||
return []
|
||||
movies: dict[str, PlexItem] = {}
|
||||
if movie_lib_ids:
|
||||
for m in db.execute(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_lib_ids),
|
||||
PlexItem.guid.in_(guids),
|
||||
)
|
||||
).scalars():
|
||||
movies.setdefault(m.guid, m)
|
||||
shows: dict[str, PlexShow] = {}
|
||||
if show_lib_ids:
|
||||
for s in db.execute(
|
||||
select(PlexShow).where(
|
||||
PlexShow.library_id.in_(show_lib_ids), PlexShow.guid.in_(guids)
|
||||
)
|
||||
).scalars():
|
||||
shows.setdefault(s.guid, s)
|
||||
m_states = {
|
||||
s.item_id: s
|
||||
for s in db.execute(
|
||||
select(PlexState).where(
|
||||
PlexState.user_id == uid,
|
||||
PlexState.item_id.in_([m.id for m in movies.values()] or [0]),
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
show_statuses = rollup.state_map(db, uid, [s.id for s in shows.values()])
|
||||
out: list[dict] = []
|
||||
for k in known_for:
|
||||
g = f"tmdb:{k.get('tmdb_id')}"
|
||||
if g in movies:
|
||||
out.append(movie_card(movies[g], m_states.get(movies[g].id)))
|
||||
elif g in shows:
|
||||
out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new")))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/people/cards")
|
||||
@@ -88,8 +144,18 @@ def person_detail(
|
||||
libraries) whose cast OR directors include this name — the inverse of the cast-name → library-filter
|
||||
link. The name string is the identity key (Plex cast carries no stable per-person id), so distinct
|
||||
people who share a name share a page. Each section is capped at _PERSON_SECTION_MAX. An unknown name
|
||||
simply returns empty sections."""
|
||||
photo = db.scalar(select(PlexPerson.photo_thumb).where(PlexPerson.name == name))
|
||||
simply returns empty sections.
|
||||
|
||||
E5 S6: the first time a page is opened, the person's TMDB bio + "Known for" credits are lazily
|
||||
fetched (from the S2-linked `tmdb_person_id`) and persisted, then served from the row on every open.
|
||||
"""
|
||||
person = db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first()
|
||||
# Lazy one-time bio enrich — best-effort, never fail the page if TMDB is down / the id is stale.
|
||||
if person is not None and person.tmdb_person_id and person.tmdb_enriched_at is None:
|
||||
try:
|
||||
plex_tmdb.enrich_person(db, person)
|
||||
except Exception: # noqa: BLE001 — enrichment is optional; the filmography still renders
|
||||
log.warning("person bio enrich failed for %s", name, exc_info=True)
|
||||
movie_lib_ids, show_lib_ids = _content_libs(db)
|
||||
|
||||
movies: list[dict] = []
|
||||
@@ -136,4 +202,19 @@ def person_detail(
|
||||
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
|
||||
shows = [show_card(sh, statuses.get(sh.id, "new")) for sh in show_rows]
|
||||
|
||||
return {"name": name, "photo": photo, "movies": movies, "shows": shows}
|
||||
known_for = _resolve_known_for(
|
||||
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids
|
||||
)
|
||||
return {
|
||||
"name": name,
|
||||
"photo": person.photo_thumb if person else None,
|
||||
"biography": person.biography if person else None,
|
||||
"birthday": person.birthday if person else None,
|
||||
"deathday": person.deathday if person else None,
|
||||
"place_of_birth": person.place_of_birth if person else None,
|
||||
"known_for_department": person.known_for_department if person else None,
|
||||
"also_known_as": (person.also_known_as if person else None) or [],
|
||||
"known_for": known_for,
|
||||
"movies": movies,
|
||||
"shows": shows,
|
||||
}
|
||||
|
||||
@@ -100,7 +100,20 @@ def test_person_detail_unknown_name_is_empty(db, monkeypatch):
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/person/Ghost%20Actor")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body == {"name": "Ghost Actor", "photo": None, "movies": [], "shows": []}
|
||||
# An unknown name has no plex_people row → no bio, no titles (E5 S6 added the bio fields, all null).
|
||||
assert body == {
|
||||
"name": "Ghost Actor",
|
||||
"photo": None,
|
||||
"biography": None,
|
||||
"birthday": None,
|
||||
"deathday": None,
|
||||
"place_of_birth": None,
|
||||
"known_for_department": None,
|
||||
"also_known_as": [],
|
||||
"known_for": [],
|
||||
"movies": [],
|
||||
"shows": [],
|
||||
}
|
||||
|
||||
|
||||
def test_content_libs_partitions_only_enabled(db):
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""E5 S6: person page TMDB bio enrichment. `parse_person` shapes the /person payload (bio + a
|
||||
popularity-ranked, deduped `known_for`); `enrich_person` lazily fetches + persists once (guarded on a
|
||||
key + a tmdb_person_id); and /person/{name} serves the bio + resolves `known_for` to library rows.
|
||||
DB lane for the endpoint/enrich tests; parse_person is pure. Skipped without Postgres where DB-backed.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexPerson, User
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
|
||||
PAYLOAD = {
|
||||
"biography": " A celebrated actor. ",
|
||||
"birthday": "1970-01-01",
|
||||
"deathday": None,
|
||||
"place_of_birth": "Springfield",
|
||||
"known_for_department": "Acting",
|
||||
"also_known_as": ["AKA One", "AKA Two"],
|
||||
"combined_credits": {
|
||||
"cast": [
|
||||
{"id": 10, "media_type": "movie", "title": "Mid Movie", "popularity": 50},
|
||||
{"id": 20, "media_type": "tv", "name": "Top Show", "popularity": 80},
|
||||
{"id": 10, "media_type": "movie", "title": "dup", "popularity": 5}, # dup id
|
||||
{"id": 30, "media_type": "movie", "title": "Low Movie", "popularity": 1},
|
||||
{"id": None, "media_type": "movie", "title": "No id", "popularity": 99}, # dropped
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_parse_person_shapes_bio_and_ranked_known_for():
|
||||
p = plex_tmdb.parse_person(PAYLOAD)
|
||||
assert p["biography"] == "A celebrated actor." # stripped
|
||||
assert p["birthday"] == "1970-01-01"
|
||||
assert p["known_for_department"] == "Acting"
|
||||
assert p["also_known_as"] == ["AKA One", "AKA Two"]
|
||||
# Popularity desc, deduped by id, None-id dropped: Top Show (80) → Mid Movie (50) → Low Movie (1).
|
||||
assert [k["tmdb_id"] for k in p["known_for"]] == [20, 10, 30]
|
||||
assert p["known_for"][0]["media_type"] == "tv"
|
||||
|
||||
|
||||
def test_parse_person_empty_payload():
|
||||
p = plex_tmdb.parse_person({})
|
||||
assert p["biography"] is None and p["known_for"] is None
|
||||
|
||||
|
||||
class _FakeTmdb:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fetch_person(self, pid):
|
||||
return self.payload
|
||||
|
||||
|
||||
def test_enrich_person_persists_and_stamps(db, monkeypatch):
|
||||
person = PlexPerson(name="Actor X", tmdb_person_id=555)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "")
|
||||
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d: _FakeTmdb(PAYLOAD))
|
||||
assert plex_tmdb.enrich_person(db, person) is True
|
||||
assert person.biography == "A celebrated actor."
|
||||
assert person.tmdb_enriched_at is not None
|
||||
assert [k["tmdb_id"] for k in person.known_for] == [20, 10, 30]
|
||||
|
||||
|
||||
def test_enrich_person_noop_without_key(db, monkeypatch):
|
||||
person = PlexPerson(name="Actor Y", tmdb_person_id=555)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "") # no key
|
||||
assert plex_tmdb.enrich_person(db, person) is False
|
||||
assert person.tmdb_enriched_at is None
|
||||
|
||||
|
||||
def test_enrich_person_noop_without_tmdb_id(db, monkeypatch):
|
||||
person = PlexPerson(name="Actor Z", tmdb_person_id=None)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key")
|
||||
assert plex_tmdb.enrich_person(db, person) is False
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
# A library movie that IS one of the person's known_for credits (matched by guid tmdb:100).
|
||||
db.add(PlexItem(rating_key="km", library_id=lib.id, kind="movie", title="Known Movie",
|
||||
guid="tmdb:100", cast_names=["Actor X"]))
|
||||
# Already-enriched person (tmdb_enriched_at set → no lazy fetch), with a known_for pool.
|
||||
from datetime import datetime, timezone
|
||||
db.add(PlexPerson(name="Actor X", tmdb_person_id=555,
|
||||
tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
biography="Bio here.", birthday="1970-01-01", known_for_department="Acting",
|
||||
also_known_as=["Alt Name"],
|
||||
known_for=[{"tmdb_id": 100, "media_type": "movie", "title": "Known Movie"},
|
||||
{"tmdb_id": 999, "media_type": "movie", "title": "Not In Library"}]))
|
||||
user = User(email="personbio@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/person/Actor X")
|
||||
assert resp.status_code == 200, resp.text
|
||||
j = resp.json()
|
||||
assert j["biography"] == "Bio here."
|
||||
assert j["birthday"] == "1970-01-01"
|
||||
assert j["also_known_as"] == ["Alt Name"]
|
||||
# Only the in-library known_for credit resolves to a card; the out-of-library one is dropped.
|
||||
assert [c["title"] for c in j["known_for"]] == ["Known Movie"]
|
||||
@@ -85,6 +85,12 @@ describe("PlexPersonView", () => {
|
||||
plexPerson.mockResolvedValue({
|
||||
name: "Jane Doe",
|
||||
photo: null,
|
||||
biography: "A celebrated actor.",
|
||||
birthday: "1970-01-01",
|
||||
place_of_birth: "Springfield",
|
||||
known_for_department: "Acting",
|
||||
also_known_as: ["Janie D"],
|
||||
known_for: [card("kf1", "Famous Film")],
|
||||
movies: [card("m1", "Movie One")],
|
||||
shows: [card("s1", "Show One", "show")],
|
||||
});
|
||||
@@ -104,13 +110,30 @@ describe("PlexPersonView", () => {
|
||||
expect(screen.getByText("plex.person.movies")).toBeTruthy(); // section headings (key passthrough)
|
||||
expect(screen.getByText("plex.person.shows")).toBeTruthy();
|
||||
|
||||
// E5 S6 bio hero: department, biography, birth facts, aka.
|
||||
expect(screen.getByText("Acting")).toBeTruthy();
|
||||
expect(screen.getByText("A celebrated actor.")).toBeTruthy();
|
||||
expect(screen.getByText(/1970-01-01/)).toBeTruthy();
|
||||
expect(screen.getByText(/plex\.person\.aka/)).toBeTruthy();
|
||||
expect(screen.getByText(/Janie D/)).toBeTruthy();
|
||||
// "Known for" rail renders its card.
|
||||
expect(screen.getByText("plex.person.knownFor")).toBeTruthy();
|
||||
expect(screen.getAllByText("Famous Film").length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByText("plex.person.filterBy"));
|
||||
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
|
||||
expect(plexPerson).toHaveBeenCalledWith("Jane Doe");
|
||||
});
|
||||
|
||||
it("shows the empty message when the person has no titles", async () => {
|
||||
plexPerson.mockResolvedValue({ name: "Nobody", photo: null, movies: [], shows: [] });
|
||||
plexPerson.mockResolvedValue({
|
||||
name: "Nobody",
|
||||
photo: null,
|
||||
also_known_as: [],
|
||||
known_for: [],
|
||||
movies: [],
|
||||
shows: [],
|
||||
});
|
||||
wrap(
|
||||
<PlexPersonView
|
||||
name="Nobody"
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
WatchedToggle,
|
||||
} from "../lib/plexDetailUi";
|
||||
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
|
||||
import { Carousel } from "./Carousel";
|
||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
@@ -119,6 +120,9 @@ export function PlexPersonView({
|
||||
...old,
|
||||
movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
|
||||
shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)),
|
||||
known_for: old.known_for.map((k) =>
|
||||
k.id === card.id ? { ...k, status: next } : k,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
),
|
||||
@@ -167,8 +171,9 @@ export function PlexPersonView({
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<div className="p-4">
|
||||
{/* Hero: headshot + name + a "filter the library by this person" action. */}
|
||||
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6 flex items-center gap-4">
|
||||
{/* Hero: headshot + name + TMDB bio/birth facts (E5 S6) + a "filter the library" action. */}
|
||||
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||
{d.photo ? (
|
||||
<img src={d.photo} alt="" className="h-full w-full object-cover" />
|
||||
@@ -178,14 +183,33 @@ export function PlexPersonView({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-semibold">{d.name}</h1>
|
||||
<p className="mb-2 text-xs text-muted">
|
||||
{t("plex.person.credits", {
|
||||
movies: d.movies.length,
|
||||
shows: d.shows.length,
|
||||
})}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-xl font-semibold">{d.name}</h1>
|
||||
{d.known_for_department && (
|
||||
<p className="text-xs font-medium text-accent">{d.known_for_department}</p>
|
||||
)}
|
||||
<p className="mt-1 mb-2 text-xs text-muted">
|
||||
{t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}
|
||||
</p>
|
||||
<div className="mb-2 space-y-0.5 text-xs text-muted">
|
||||
{(d.birthday || d.place_of_birth) && (
|
||||
<p>
|
||||
<span className="text-fg/70">{t("plex.person.born")}:</span>{" "}
|
||||
{[d.birthday, d.place_of_birth].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
{d.deathday && (
|
||||
<p>
|
||||
<span className="text-fg/70">{t("plex.person.died")}:</span> {d.deathday}
|
||||
</p>
|
||||
)}
|
||||
{d.also_known_as.length > 0 && (
|
||||
<p className="truncate">
|
||||
<span className="text-fg/70">{t("plex.person.aka")}:</span>{" "}
|
||||
{d.also_known_as.slice(0, 4).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onFilter({ actors: [d.name] })}
|
||||
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
|
||||
@@ -193,7 +217,32 @@ export function PlexPersonView({
|
||||
{t("plex.person.filterBy", { name: d.name })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{d.biography && (
|
||||
<p className="mt-4 whitespace-pre-line text-sm leading-relaxed text-muted">
|
||||
{d.biography}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */}
|
||||
{d.known_for.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<Carousel title={t("plex.person.knownFor")} ariaLabel={t("plex.person.knownFor")}>
|
||||
{d.known_for.map((c) => (
|
||||
<div key={c.id} className="w-[150px] shrink-0">
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
onToggleWatched={toggle}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{empty ? (
|
||||
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
|
||||
) : (
|
||||
|
||||
@@ -266,7 +266,11 @@
|
||||
"filterBy": "Filter library by {{name}}",
|
||||
"movies": "Movies ({{count}})",
|
||||
"shows": "Shows ({{count}})",
|
||||
"empty": "No titles found for this person."
|
||||
"empty": "No titles found for this person.",
|
||||
"born": "Born",
|
||||
"died": "Died",
|
||||
"aka": "Also known as",
|
||||
"knownFor": "Known for"
|
||||
},
|
||||
"view": {
|
||||
"label": "View",
|
||||
|
||||
@@ -266,7 +266,11 @@
|
||||
"filterBy": "Szűrés erre: {{name}}",
|
||||
"movies": "Filmek ({{count}})",
|
||||
"shows": "Sorozatok ({{count}})",
|
||||
"empty": "Nincs találat ehhez a személyhez."
|
||||
"empty": "Nincs találat ehhez a személyhez.",
|
||||
"born": "Született",
|
||||
"died": "Elhunyt",
|
||||
"aka": "Más néven",
|
||||
"knownFor": "Ismert szerepei"
|
||||
},
|
||||
"view": {
|
||||
"label": "Nézet",
|
||||
|
||||
@@ -211,6 +211,15 @@ export interface PlexPersonCard {
|
||||
export interface PlexPersonDetail {
|
||||
name: string;
|
||||
photo?: string | null; // cached headshot, or null (placeholder)
|
||||
// TMDB bio (E5 S6), lazily fetched on first open, then served from the row. Null when the person has
|
||||
// no linked tmdb_person_id (never appeared in a TMDB-enriched title) or TMDB has no data.
|
||||
biography?: string | null;
|
||||
birthday?: string | null; // "YYYY-MM-DD"
|
||||
deathday?: string | null;
|
||||
place_of_birth?: string | null;
|
||||
known_for_department?: string | null;
|
||||
also_known_as: string[];
|
||||
known_for: PlexCard[]; // their most-popular credits that are IN our library (deep-linkable)
|
||||
movies: PlexCard[]; // enabled-library movies whose cast OR directors include this person
|
||||
shows: PlexCard[]; // enabled-library shows, same
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user