diff --git a/backend/alembic/versions/0072_plex_person_bio.py b/backend/alembic/versions/0072_plex_person_bio.py new file mode 100644 index 00000000..5ef253fd --- /dev/null +++ b/backend/alembic/versions/0072_plex_person_bio.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index 0a4cfe9b..e449b185 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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): diff --git a/backend/app/plex/tmdb.py b/backend/app/plex/tmdb.py index 5779664e..91da0294 100644 --- a/backend/app/plex/tmdb.py +++ b/backend/app/plex/tmdb.py @@ -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 diff --git a/backend/app/routes/plex/people.py b/backend/app/routes/plex/people.py index d68da63f..203d4130 100644 --- a/backend/app/routes/plex/people.py +++ b/backend/app/routes/plex/people.py @@ -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:`; 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, + } diff --git a/backend/tests/test_plex_person.py b/backend/tests/test_plex_person.py index 0caefac7..4709074d 100644 --- a/backend/tests/test_plex_person.py +++ b/backend/tests/test_plex_person.py @@ -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): diff --git a/backend/tests/test_plex_person_bio.py b/backend/tests/test_plex_person_bio.py new file mode 100644 index 00000000..34382e0c --- /dev/null +++ b/backend/tests/test_plex_person_bio.py @@ -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"] diff --git a/frontend/src/components/PlexPersonView.dom.test.tsx b/frontend/src/components/PlexPersonView.dom.test.tsx index c6fb03b4..2954b48b 100644 --- a/frontend/src/components/PlexPersonView.dom.test.tsx +++ b/frontend/src/components/PlexPersonView.dom.test.tsx @@ -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( (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({ ) : (
- {/* Hero: headshot + name + a "filter the library by this person" action. */} -
-
- {d.photo ? ( - - ) : ( -
- + {/* Hero: headshot + name + TMDB bio/birth facts (E5 S6) + a "filter the library" action. */} +
+
+
+ {d.photo ? ( + + ) : ( +
+ +
+ )} +
+
+

{d.name}

+ {d.known_for_department && ( +

{d.known_for_department}

+ )} +

+ {t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })} +

+
+ {(d.birthday || d.place_of_birth) && ( +

+ {t("plex.person.born")}:{" "} + {[d.birthday, d.place_of_birth].filter(Boolean).join(" · ")} +

+ )} + {d.deathday && ( +

+ {t("plex.person.died")}: {d.deathday} +

+ )} + {d.also_known_as.length > 0 && ( +

+ {t("plex.person.aka")}:{" "} + {d.also_known_as.slice(0, 4).join(", ")} +

+ )}
- )} + +
-
-

{d.name}

-

- {t("plex.person.credits", { - movies: d.movies.length, - shows: d.shows.length, - })} + {d.biography && ( +

+ {d.biography}

- -
+ )}
+ + {/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */} + {d.known_for.length > 0 && ( +
+ + {d.known_for.map((c) => ( +
+ onOpenCard(c)} + onInfo={() => onInfoCard(c)} + onToggleWatched={toggle} + /> +
+ ))} +
+
+ )} + {empty ? (

{t("plex.person.empty")}

) : ( diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 9467c9b3..0eeb4b79 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index da220a4d..01fb93f8 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -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", diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 87cb4486..ca04efa5 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -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 }