From 74adcf77bc1bc238fd7b79c61937916815b47f7e Mon Sep 17 00:00:00 2001 From: NixReaper Date: Sat, 5 Sep 2026 09:52:08 -0500 Subject: [PATCH] Add series endpoint parity: /credits, /images, /videos Movies have separate /credits, /images, /videos sub-endpoints; series only had /seasons. Adds the matching three for series, reusing the same cast/crew/creators three-way split series detail already does (extracted into _split_series_credits so both share it). Co-Authored-By: Claude Sonnet 5 --- app/api/v1/series.py | 39 +++++++++++- app/schemas/tv.py | 17 +++++ app/services/tv_service.py | 51 ++++++++++++++- docs/api.md | 8 ++- tests/integration/test_series_ingestion.py | 74 ++++++++++++++++++++++ 5 files changed, 184 insertions(+), 5 deletions(-) diff --git a/app/api/v1/series.py b/app/api/v1/series.py index 3422e8e..63d8b4d 100644 --- a/app/api/v1/series.py +++ b/app/api/v1/series.py @@ -12,7 +12,14 @@ from app.api.deps import cached_response, not_found from app.core.database import get_db from app.core.redis import get_redis from app.core.security import enforce_rate_limit -from app.schemas.tv import SeriesDetailOut, SeriesListOut, SeriesSeasonsOut +from app.schemas.tv import ( + SeriesCreditsOut, + SeriesDetailOut, + SeriesImagesOut, + SeriesListOut, + SeriesSeasonsOut, + SeriesVideosOut, +) from app.services import tv_service from app.services.cache import build_key @@ -70,3 +77,33 @@ async def get_series_seasons( return await tv_service.get_series_seasons(session, aurora_id) except tv_service.SeriesNotFoundError as exc: raise not_found("series", aurora_id) from exc + + +@router.get("/{aurora_id}/credits", response_model=SeriesCreditsOut) +async def get_series_credits( + aurora_id: str, session: AsyncSession = Depends(get_db) +) -> SeriesCreditsOut: + try: + return await tv_service.get_series_credits(session, aurora_id) + except tv_service.SeriesNotFoundError as exc: + raise not_found("series", aurora_id) from exc + + +@router.get("/{aurora_id}/images", response_model=SeriesImagesOut) +async def get_series_images( + aurora_id: str, session: AsyncSession = Depends(get_db) +) -> SeriesImagesOut: + try: + return await tv_service.get_series_images(session, aurora_id) + except tv_service.SeriesNotFoundError as exc: + raise not_found("series", aurora_id) from exc + + +@router.get("/{aurora_id}/videos", response_model=SeriesVideosOut) +async def get_series_videos( + aurora_id: str, session: AsyncSession = Depends(get_db) +) -> SeriesVideosOut: + try: + return await tv_service.get_series_videos(session, aurora_id) + except tv_service.SeriesNotFoundError as exc: + raise not_found("series", aurora_id) from exc diff --git a/app/schemas/tv.py b/app/schemas/tv.py index 4a456a3..3a10343 100644 --- a/app/schemas/tv.py +++ b/app/schemas/tv.py @@ -76,6 +76,23 @@ class SeriesSeasonsOut(BaseModel): seasons: list[SeasonBriefOut] = [] +class SeriesCreditsOut(BaseModel): + id: str + cast: list[CastCreditOut] = [] + crew: list[CrewCreditOut] = [] + creators: list[CrewCreditOut] = [] + + +class SeriesImagesOut(BaseModel): + id: str + images: ImagesOut = ImagesOut() + + +class SeriesVideosOut(BaseModel): + id: str + videos: list[VideoOut] = [] + + class SeriesListOut(BaseModel): page: int page_size: int diff --git a/app/services/tv_service.py b/app/services/tv_service.py index 16bbd96..d32e17b 100644 --- a/app/services/tv_service.py +++ b/app/services/tv_service.py @@ -7,19 +7,23 @@ import uuid 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, CompanyType, SeriesStatus from app.models.tv import Series from app.repositories import polymorphic, tv_repository -from app.schemas.common import CompanyBriefOut, GenreOut, KeywordOut +from app.schemas.common import CastCreditOut, CompanyBriefOut, CrewCreditOut, GenreOut, KeywordOut from app.schemas.tv import ( EpisodeBriefOut, EpisodeDetailOut, SeasonBriefOut, SeasonDetailOut, + SeriesCreditsOut, SeriesDetailOut, + SeriesImagesOut, SeriesListOut, SeriesSeasonsOut, SeriesSummaryOut, + SeriesVideosOut, ) from app.services.common import primary_artwork_path, split_credits, to_images_out, to_video_out @@ -69,6 +73,16 @@ async def _company_brief(session: AsyncSession, company) -> CompanyBriefOut: ) +def _split_series_credits( + credits_: list[Credit], +) -> tuple[list[CastCreditOut], list[CrewCreditOut], list[CrewCreditOut]]: + # Series-level "Creator" credits are their own bucket (section 7), + # neither cast nor regular crew. + cast, crew = split_credits([c for c in credits_ if c.department != "Creator"]) + _, creators = split_credits([c for c in credits_ if c.department == "Creator"]) + return cast, crew, creators + + async def get_series_detail(session: AsyncSession, series_aurora_id: str) -> SeriesDetailOut: series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError) series = await tv_repository.get_series_by_id(session, series_id) @@ -87,8 +101,7 @@ async def get_series_detail(session: AsyncSession, series_aurora_id: str) -> Ser videos = await polymorphic.get_videos(session, "series", series_id) external_ids = await polymorphic.get_external_ids(session, "series", series_id) - cast, crew = split_credits([c for c in credits_ if c.department != "Creator"]) - _, creators = split_credits([c for c in credits_ if c.department == "Creator"]) + cast, crew, creators = _split_series_credits(credits_) season_briefs = [await _season_brief(session, s) for s in seasons] network_briefs = [await _company_brief(session, c) for c, _role in networks] @@ -135,6 +148,38 @@ async def get_series_seasons(session: AsyncSession, series_aurora_id: str) -> Se return SeriesSeasonsOut(id=aurora_id("series", str(series.id)), seasons=briefs) +async def get_series_credits(session: AsyncSession, series_aurora_id: str) -> SeriesCreditsOut: + series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError) + series = await tv_repository.get_series_by_id(session, series_id) + if series is None: + raise SeriesNotFoundError(series_aurora_id) + credits_ = await polymorphic.get_credits(session, "series", series_id) + cast, crew, creators = _split_series_credits(credits_) + return SeriesCreditsOut( + id=aurora_id("series", str(series.id)), cast=cast, crew=crew, creators=creators + ) + + +async def get_series_images(session: AsyncSession, series_aurora_id: str) -> SeriesImagesOut: + series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError) + series = await tv_repository.get_series_by_id(session, series_id) + if series is None: + raise SeriesNotFoundError(series_aurora_id) + artwork = await polymorphic.get_artwork(session, "series", series_id) + return SeriesImagesOut(id=aurora_id("series", str(series.id)), images=to_images_out(artwork)) + + +async def get_series_videos(session: AsyncSession, series_aurora_id: str) -> SeriesVideosOut: + series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError) + series = await tv_repository.get_series_by_id(session, series_id) + if series is None: + raise SeriesNotFoundError(series_aurora_id) + videos = await polymorphic.get_videos(session, "series", series_id) + return SeriesVideosOut( + id=aurora_id("series", str(series.id)), videos=[to_video_out(v) for v in videos] + ) + + async def get_season_detail(session: AsyncSession, season_aurora_id: str) -> SeasonDetailOut: season_id = _resolve_id(season_aurora_id, "season", SeasonNotFoundError) season = await tv_repository.get_season_by_id(session, season_id) diff --git a/docs/api.md b/docs/api.md index 4b5d79c..b27e103 100644 --- a/docs/api.md +++ b/docs/api.md @@ -126,6 +126,9 @@ ingested: | GET | `/api/v1/series` | | GET | `/api/v1/series/{aurora_id}` | | GET | `/api/v1/series/{aurora_id}/seasons` | +| GET | `/api/v1/series/{aurora_id}/credits` | +| GET | `/api/v1/series/{aurora_id}/images` | +| GET | `/api/v1/series/{aurora_id}/videos` | | GET | `/api/v1/seasons/{aurora_id}` | | GET | `/api/v1/episodes/{aurora_id}` | @@ -138,7 +141,10 @@ and validation behavior. Series detail includes `networks` and `companies` as separate lists (a TV network and a production company are both `Company` rows distinguished by `company_type` — see `docs/database.md`), plus `creators` (series-level -"Creator" credits) alongside `cast`/`crew`. +"Creator" credits) alongside `cast`/`crew`. `/credits`, `/images`, and +`/videos` are the same sub-resources broken out on their own, mirroring the +movie endpoints above (parity that was missing until now) — `/credits` +carries the same `cast`/`crew`/`creators` three-way split as series detail. ## People diff --git a/tests/integration/test_series_ingestion.py b/tests/integration/test_series_ingestion.py index acfc99f..28febe5 100644 --- a/tests/integration/test_series_ingestion.py +++ b/tests/integration/test_series_ingestion.py @@ -5,6 +5,7 @@ path against PostgreSQL.""" from __future__ import annotations +import uuid from typing import Any import pytest @@ -140,3 +141,76 @@ async def test_series_detail_surfaces_network_logo_path(db_session: AsyncSession gran_via = next(c for c in detail.companies if c.name == "Gran Via Productions") assert gran_via.logo_path is None + + +@pytest.mark.asyncio +async def test_get_series_credits_separates_cast_crew_and_creators(db_session: AsyncSession): + provider = FakeTMDbSeriesProvider(FAKE_SERIES, FAKE_SEASON) + series_id = await ingest_series(db_session, provider, "1396") + await db_session.commit() + + credits_out = await tv_service.get_series_credits( + db_session, aurora_id("series", str(series_id)) + ) + + assert [c.person.name for c in credits_out.cast] == ["Bryan Cranston"] + assert credits_out.cast[0].character == "Walter White" + assert credits_out.crew == [] + assert [c.person.name for c in credits_out.creators] == ["Vince Gilligan"] + + +@pytest.mark.asyncio +async def test_get_series_images_returns_poster_and_backdrop(db_session: AsyncSession): + provider = FakeTMDbSeriesProvider(FAKE_SERIES, FAKE_SEASON) + series_id = await ingest_series(db_session, provider, "1396") + await db_session.commit() + + images_out = await tv_service.get_series_images( + db_session, aurora_id("series", str(series_id)) + ) + + assert len(images_out.images.posters) == 1 + assert images_out.images.posters[0].url.endswith("/poster.jpg") + assert len(images_out.images.backdrops) == 1 + + +@pytest.mark.asyncio +async def test_get_series_videos_returns_normalized_videos(db_session: AsyncSession): + payload = { + **FAKE_SERIES, + "videos": { + "results": [ + { + "id": "abc123", + "site": "YouTube", + "key": "dQw4w9WgXcQ", + "type": "Trailer", + "official": True, + "iso_639_1": "en", + "iso_3166_1": "US", + } + ] + }, + } + provider = FakeTMDbSeriesProvider(payload, FAKE_SEASON) + series_id = await ingest_series(db_session, provider, "1396") + await db_session.commit() + + videos_out = await tv_service.get_series_videos( + db_session, aurora_id("series", str(series_id)) + ) + + assert len(videos_out.videos) == 1 + assert videos_out.videos[0].site == "YouTube" + assert videos_out.videos[0].is_official is True + + +@pytest.mark.asyncio +async def test_series_sub_endpoints_raise_not_found_for_unknown_series(db_session: AsyncSession): + unknown_id = aurora_id("series", str(uuid.uuid4())) + with pytest.raises(tv_service.SeriesNotFoundError): + await tv_service.get_series_credits(db_session, unknown_id) + with pytest.raises(tv_service.SeriesNotFoundError): + await tv_service.get_series_images(db_session, unknown_id) + with pytest.raises(tv_service.SeriesNotFoundError): + await tv_service.get_series_videos(db_session, unknown_id)