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:
2026-08-21 00:08:19 +02:00
parent f10915c223
commit 187b8007f8
11 changed files with 462 additions and 30 deletions
@@ -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"
+72 -23
View File
@@ -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,33 +171,78 @@ 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">
<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" />
) : (
<div className="grid h-full w-full place-items-center opacity-40">
<User className="h-8 w-8" />
{/* 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" />
) : (
<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>
)}
<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 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,
})}
{d.biography && (
<p className="mt-4 whitespace-pre-line text-sm leading-relaxed text-muted">
{d.biography}
</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>
{/* "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>
) : (
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+9
View File
@@ -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
}