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
|
# 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.
|
# 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_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):
|
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ class TmdbClient:
|
|||||||
"""Resolve an external id (imdb_id / tvdb_id) to TMDB items via /find."""
|
"""Resolve an external id (imdb_id / tvdb_id) to TMDB items via /find."""
|
||||||
return self._get(f"/find/{external_id}", {"external_source": source})
|
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:
|
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
|
"""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),
|
"recommendations": resolve_recs(db, row.recommendations),
|
||||||
"backdrops": row.backdrops or [],
|
"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."""
|
"""Plex people: cast/crew filter-chip cards + a single person's filmography page."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -14,11 +16,65 @@ from app.models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.plex import rollup
|
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.cards import movie_card, show_card
|
||||||
from app.plex.query import split_csv
|
from app.plex.query import split_csv
|
||||||
from app.routes.plex._shared import _content_libs
|
from app.routes.plex._shared import _content_libs
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
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")
|
@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
|
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
|
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
|
people who share a name share a page. Each section is capped at _PERSON_SECTION_MAX. An unknown name
|
||||||
simply returns empty sections."""
|
simply returns empty sections.
|
||||||
photo = db.scalar(select(PlexPerson.photo_thumb).where(PlexPerson.name == name))
|
|
||||||
|
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)
|
movie_lib_ids, show_lib_ids = _content_libs(db)
|
||||||
|
|
||||||
movies: list[dict] = []
|
movies: list[dict] = []
|
||||||
@@ -136,4 +202,19 @@ def person_detail(
|
|||||||
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
|
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]
|
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")
|
resp = _get(db, user, monkeypatch, "/api/plex/person/Ghost%20Actor")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
body = resp.json()
|
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):
|
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({
|
plexPerson.mockResolvedValue({
|
||||||
name: "Jane Doe",
|
name: "Jane Doe",
|
||||||
photo: null,
|
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")],
|
movies: [card("m1", "Movie One")],
|
||||||
shows: [card("s1", "Show One", "show")],
|
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.movies")).toBeTruthy(); // section headings (key passthrough)
|
||||||
expect(screen.getByText("plex.person.shows")).toBeTruthy();
|
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"));
|
fireEvent.click(screen.getByText("plex.person.filterBy"));
|
||||||
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
|
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
|
||||||
expect(plexPerson).toHaveBeenCalledWith("Jane Doe");
|
expect(plexPerson).toHaveBeenCalledWith("Jane Doe");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the empty message when the person has no titles", async () => {
|
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(
|
wrap(
|
||||||
<PlexPersonView
|
<PlexPersonView
|
||||||
name="Nobody"
|
name="Nobody"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
WatchedToggle,
|
WatchedToggle,
|
||||||
} from "../lib/plexDetailUi";
|
} from "../lib/plexDetailUi";
|
||||||
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
|
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
|
||||||
|
import { Carousel } from "./Carousel";
|
||||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
@@ -119,6 +120,9 @@ export function PlexPersonView({
|
|||||||
...old,
|
...old,
|
||||||
movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
|
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)),
|
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,
|
: old,
|
||||||
),
|
),
|
||||||
@@ -167,33 +171,78 @@ export function PlexPersonView({
|
|||||||
<LoadingState label={t("plex.loading")} />
|
<LoadingState label={t("plex.loading")} />
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{/* Hero: headshot + name + a "filter the library by this person" action. */}
|
{/* 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 flex items-center gap-4">
|
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6">
|
||||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
<div className="flex items-start gap-4">
|
||||||
{d.photo ? (
|
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||||
<img src={d.photo} alt="" className="h-full w-full object-cover" />
|
{d.photo ? (
|
||||||
) : (
|
<img src={d.photo} alt="" className="h-full w-full object-cover" />
|
||||||
<div className="grid h-full w-full place-items-center opacity-40">
|
) : (
|
||||||
<User className="h-8 w-8" />
|
<div className="grid h-full w-full place-items-center opacity-40">
|
||||||
|
<User className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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>
|
</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"
|
||||||
|
>
|
||||||
|
{t("plex.person.filterBy", { name: d.name })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
{d.biography && (
|
||||||
<h1 className="truncate text-xl font-semibold">{d.name}</h1>
|
<p className="mt-4 whitespace-pre-line text-sm leading-relaxed text-muted">
|
||||||
<p className="mb-2 text-xs text-muted">
|
{d.biography}
|
||||||
{t("plex.person.credits", {
|
|
||||||
movies: d.movies.length,
|
|
||||||
shows: d.shows.length,
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{t("plex.person.filterBy", { name: d.name })}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</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 ? (
|
{empty ? (
|
||||||
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
|
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -266,7 +266,11 @@
|
|||||||
"filterBy": "Filter library by {{name}}",
|
"filterBy": "Filter library by {{name}}",
|
||||||
"movies": "Movies ({{count}})",
|
"movies": "Movies ({{count}})",
|
||||||
"shows": "Shows ({{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": {
|
"view": {
|
||||||
"label": "View",
|
"label": "View",
|
||||||
|
|||||||
@@ -266,7 +266,11 @@
|
|||||||
"filterBy": "Szűrés erre: {{name}}",
|
"filterBy": "Szűrés erre: {{name}}",
|
||||||
"movies": "Filmek ({{count}})",
|
"movies": "Filmek ({{count}})",
|
||||||
"shows": "Sorozatok ({{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": {
|
"view": {
|
||||||
"label": "Nézet",
|
"label": "Nézet",
|
||||||
|
|||||||
@@ -211,6 +211,15 @@ export interface PlexPersonCard {
|
|||||||
export interface PlexPersonDetail {
|
export interface PlexPersonDetail {
|
||||||
name: string;
|
name: string;
|
||||||
photo?: string | null; // cached headshot, or null (placeholder)
|
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
|
movies: PlexCard[]; // enabled-library movies whose cast OR directors include this person
|
||||||
shows: PlexCard[]; // enabled-library shows, same
|
shows: PlexCard[]; // enabled-library shows, same
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user