From ec6b5bdd7d8459c4cd041194358e8edb13092f6c Mon Sep 17 00:00:00 2001 From: NixReaper Date: Fri, 4 Sep 2026 20:00:15 -0500 Subject: [PATCH] Surface series/episode credits in person filmography /people/{id}/credits only ever looked at entity_type == "movie", silently dropping the series-level cast/crew and episode-level guest-star Credit rows that series_ingestion.py has been writing all along -- a person ingested via a TV show would show an empty or movie-only filmography no matter how much TV work was actually in the database. Regular series credits map straight to a filmography row, same shape as movies. Guest-star episode credits are aggregated to one row per (series, department/job/character) with an episode_count, rather than one row per episode -- a prolific guest actor would otherwise flood the list with dozens of near-identical entries. Added tv_repository.get_series_by_ids/get_episodes_by_ids (bulk, mirroring the existing movie_repository pattern) to support it. Co-Authored-By: Claude Sonnet 5 --- app/repositories/tv_repository.py | 18 ++++ app/schemas/person.py | 5 + app/services/person_service.py | 122 ++++++++++++++++++----- docs/api.md | 10 +- tests/integration/test_person_credits.py | 101 +++++++++++++++++++ 5 files changed, 228 insertions(+), 28 deletions(-) create mode 100644 tests/integration/test_person_credits.py diff --git a/app/repositories/tv_repository.py b/app/repositories/tv_repository.py index 7c389ca..3098a19 100644 --- a/app/repositories/tv_repository.py +++ b/app/repositories/tv_repository.py @@ -19,6 +19,24 @@ async def get_series_by_id(session: AsyncSession, series_id: uuid.UUID) -> Serie return result.scalar_one_or_none() +async def get_series_by_ids(session: AsyncSession, series_ids: list[uuid.UUID]) -> list[Series]: + if not series_ids: + return [] + stmt = select(Series).where(Series.id.in_(series_ids)) + result = await session.execute(stmt) + by_id = {s.id: s for s in result.scalars().all()} + return [by_id[i] for i in series_ids if i in by_id] + + +async def get_episodes_by_ids(session: AsyncSession, episode_ids: list[uuid.UUID]) -> list[Episode]: + if not episode_ids: + return [] + stmt = select(Episode).where(Episode.id.in_(episode_ids)) + result = await session.execute(stmt) + by_id = {e.id: e for e in result.scalars().all()} + return [by_id[i] for i in episode_ids if i in by_id] + + async def get_season_by_number( session: AsyncSession, series_id: uuid.UUID, season_number: int ) -> Season | None: diff --git a/app/schemas/person.py b/app/schemas/person.py index 1c51419..b8c6090 100644 --- a/app/schemas/person.py +++ b/app/schemas/person.py @@ -28,6 +28,11 @@ class FilmographyCreditOut(BaseModel): character: str | None = None release_date: date | None = None poster_path: str | None = None + # Set only for series filmography built from guest-star episode credits: + # how many distinct episodes this character/credit appeared in, since + # those are aggregated to one row per series rather than one per + # episode. None for movie credits and regular (non-guest) series roles. + episode_count: int | None = None class PersonDetailOut(BaseModel): diff --git a/app/services/person_service.py b/app/services/person_service.py index dee1b7f..3f38524 100644 --- a/app/services/person_service.py +++ b/app/services/person_service.py @@ -3,12 +3,15 @@ from __future__ import annotations import uuid +from collections import defaultdict from sqlalchemy.ext.asyncio import AsyncSession from app.core.ids import aurora_id, parse_aurora_id +from app.models.credit import Credit from app.models.enums import ArtworkType -from app.repositories import movie_repository, person_repository, polymorphic +from app.models.tv import Series +from app.repositories import movie_repository, person_repository, polymorphic, tv_repository from app.schemas.person import ( FilmographyCreditOut, PersonCreditsOut, @@ -56,6 +59,11 @@ async def get_person_detail(session: AsyncSession, person_aurora_id: str) -> Per ) +async def _series_poster_path(session: AsyncSession, series_id: uuid.UUID) -> str | None: + posters = await polymorphic.get_artwork(session, "series", series_id, ArtworkType.POSTER) + return primary_artwork_path(posters, ArtworkType.POSTER) + + async def get_person_credits(session: AsyncSession, person_aurora_id: str) -> PersonCreditsOut: person_id = _resolve_person_id(person_aurora_id) person = await person_repository.get_person_by_id(session, person_id) @@ -64,38 +72,104 @@ async def get_person_credits(session: AsyncSession, person_aurora_id: str) -> Pe credits_ = await person_repository.get_person_credits(session, person_id) - movie_ids = [c.entity_id for c in credits_ if c.entity_type == "movie"] - movies = {m.id: m for m in await movie_repository.get_movies_by_ids(session, movie_ids)} + movie_credits = [c for c in credits_ if c.entity_type == "movie"] + series_credits = [c for c in credits_ if c.entity_type == "series"] + episode_credits = [c for c in credits_ if c.entity_type == "episode"] + + movies = { + m.id: m + for m in await movie_repository.get_movies_by_ids( + session, [c.entity_id for c in movie_credits] + ) + } + episodes = { + e.id: e + for e in await tv_repository.get_episodes_by_ids( + session, [c.entity_id for c in episode_credits] + ) + } + + # Guest-star episode credits are aggregated to one filmography row per + # (series, department/job/character) rather than one row per episode — + # a prolific guest actor would otherwise flood the list with dozens of + # near-identical entries, one per episode they appeared in. + episode_groups: dict[tuple, list[Credit]] = defaultdict(list) + for credit in episode_credits: + episode = episodes.get(credit.entity_id) + if episode is None: + continue + key = (episode.series_id, credit.department, credit.job, credit.character) + episode_groups[key].append(credit) + + series_ids = {c.entity_id for c in series_credits} | { + episode.series_id for episode in episodes.values() + } + series_by_id: dict[uuid.UUID, Series] = { + s.id: s for s in await tv_repository.get_series_by_ids(session, list(series_ids)) + } cast: list[FilmographyCreditOut] = [] crew: list[FilmographyCreditOut] = [] - for credit in credits_: - if credit.entity_type != "movie": - # Series/episode filmography follows the same shape; movies are - # implemented first per the Core 0.1 milestone (section 34). - continue + def _bucket(department: str) -> list[FilmographyCreditOut]: + return cast if department == "Acting" else crew + + for credit in movie_credits: movie = movies.get(credit.entity_id) if movie is None: continue - posters = await polymorphic.get_artwork( - session, "movie", movie.id, ArtworkType.POSTER + posters = await polymorphic.get_artwork(session, "movie", movie.id, ArtworkType.POSTER) + _bucket(credit.department).append( + FilmographyCreditOut( + id=aurora_id("credit", str(credit.id)), + media_id=aurora_id("movie", str(movie.id)), + media_type="movie", + title=movie.title, + department=credit.department, + job=credit.job, + character=credit.character, + release_date=movie.primary_release_date, + poster_path=primary_artwork_path(posters, ArtworkType.POSTER), + ) ) - item = FilmographyCreditOut( - id=aurora_id("credit", str(credit.id)), - media_id=aurora_id("movie", str(movie.id)), - media_type="movie", - title=movie.title, - department=credit.department, - job=credit.job, - character=credit.character, - release_date=movie.primary_release_date, - poster_path=primary_artwork_path(posters, ArtworkType.POSTER), + + for credit in series_credits: + series = series_by_id.get(credit.entity_id) + if series is None: + continue + _bucket(credit.department).append( + FilmographyCreditOut( + id=aurora_id("credit", str(credit.id)), + media_id=aurora_id("series", str(series.id)), + media_type="series", + title=series.title, + department=credit.department, + job=credit.job, + character=credit.character, + release_date=series.first_air_date, + poster_path=await _series_poster_path(session, series.id), + ) + ) + + for (series_id, department, job, character), group in episode_groups.items(): + series = series_by_id.get(series_id) + if series is None: + continue + representative = group[0] + _bucket(department).append( + FilmographyCreditOut( + id=aurora_id("credit", str(representative.id)), + media_id=aurora_id("series", str(series.id)), + media_type="series", + title=series.title, + department=department, + job=job, + character=character, + release_date=series.first_air_date, + poster_path=await _series_poster_path(session, series.id), + episode_count=len(group), + ) ) - if credit.department == "Acting": - cast.append(item) - else: - crew.append(item) return PersonCreditsOut(id=aurora_id("person", str(person.id)), cast=cast, crew=crew) diff --git a/docs/api.md b/docs/api.md index 0ca8f4f..63e9874 100644 --- a/docs/api.md +++ b/docs/api.md @@ -101,7 +101,7 @@ network and a production company are both `Company` rows distinguished by | Method | Path | |---|---| | GET | `/api/v1/people/{aurora_id}` | -| GET | `/api/v1/people/{aurora_id}/credits` (movie filmography in 0.1; series/episode credits are stored but not yet surfaced here — see Known limitations) | +| GET | `/api/v1/people/{aurora_id}/credits` (movie and series filmography; guest-star episode credits are aggregated to one row per series with `episode_count`, not one row per episode) | ## Collections / Companies @@ -140,6 +140,8 @@ you want network-level isolation too. (movie/series lists like recommendations return everything). Cursor-based pagination using the Aurora ID's inherent creation-order sort (see `docs/database.md#aurora-ids-vs-primary-keys`) is the natural next step. -- `/people/{id}/credits` only returns movie credits today; series/episode - credits exist in the `credits` table but the response assembly for them - is not yet wired up (`app/services/person_service.py`). +- `/people/{id}/credits` covers movies and series (episode guest-star + credits rolled up per series). It does not yet expose a per-episode + breakdown for those guest appearances (just the aggregate + `episode_count`) — the individual episode `Credit` rows exist in the + database if that's ever needed. diff --git a/tests/integration/test_person_credits.py b/tests/integration/test_person_credits.py new file mode 100644 index 0000000..8ed7930 --- /dev/null +++ b/tests/integration/test_person_credits.py @@ -0,0 +1,101 @@ +"""Integration tests for person filmography assembly +(app/services/person_service.py:get_person_credits) — movies, regular +series roles, and guest-star episode credits aggregated per series.""" + +from __future__ import annotations + +from datetime import date + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.ids import aurora_id +from app.models.credit import Credit +from app.models.movie import Movie +from app.models.person import Person +from app.models.tv import Episode, Season, Series +from app.services.person_service import get_person_credits + + +@pytest.mark.asyncio +async def test_person_credits_include_movies_series_and_aggregated_guest_spots( + db_session: AsyncSession, +): + person = Person(name="Guest Actor") + db_session.add(person) + await db_session.flush() + + movie = Movie(title="A Movie", sort_title="Movie", primary_release_date=date(2020, 1, 1)) + db_session.add(movie) + await db_session.flush() + db_session.add( + Credit( + person_id=person.id, + entity_type="movie", + entity_id=movie.id, + department="Acting", + character="Hero", + ) + ) + + series = Series( + title="A Series", sort_title="Series", first_air_date=date(2019, 5, 1) + ) + db_session.add(series) + await db_session.flush() + db_session.add( + Credit( + person_id=person.id, + entity_type="series", + entity_id=series.id, + department="Acting", + character="Regular Character", + ) + ) + + season = Season(series_id=series.id, season_number=1) + db_session.add(season) + await db_session.flush() + + episode1 = Episode( + series_id=series.id, season_id=season.id, season_number=1, episode_number=1 + ) + episode2 = Episode( + series_id=series.id, season_id=season.id, season_number=1, episode_number=2 + ) + db_session.add_all([episode1, episode2]) + await db_session.flush() + + for episode in (episode1, episode2): + db_session.add( + Credit( + person_id=person.id, + entity_type="episode", + entity_id=episode.id, + department="Acting", + character="Guest Character", + is_guest=True, + ) + ) + await db_session.commit() + + credits_out = await get_person_credits(db_session, aurora_id("person", str(person.id))) + + titles = {c.title for c in credits_out.cast} + assert titles == {"A Movie", "A Series"} + + movie_credit = next(c for c in credits_out.cast if c.media_type == "movie") + assert movie_credit.character == "Hero" + assert movie_credit.episode_count is None + + # Both the regular-cast series credit and the two guest-star episode + # credits point at the same series, but must NOT collapse into one + # entry -- they have different characters/roles. + series_entries = [c for c in credits_out.cast if c.media_type == "series"] + assert len(series_entries) == 2 + + regular = next(c for c in series_entries if c.character == "Regular Character") + assert regular.episode_count is None + + guest = next(c for c in series_entries if c.character == "Guest Character") + assert guest.episode_count == 2