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:
@@ -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,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>
|
||||
) : (
|
||||
|
||||
@@ -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