diff --git a/app/repositories/company_repository.py b/app/repositories/company_repository.py index ee55e04..c3d43aa 100644 --- a/app/repositories/company_repository.py +++ b/app/repositories/company_repository.py @@ -7,9 +7,33 @@ import uuid from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.models.company import Company +from app.models.company import Company, MovieCompany, SeriesCompany +from app.models.movie import Movie +from app.models.tv import Series async def get_company_by_id(session: AsyncSession, company_id: uuid.UUID) -> Company | None: result = await session.execute(select(Company).where(Company.id == company_id)) return result.scalar_one_or_none() + + +async def get_company_movies(session: AsyncSession, company_id: uuid.UUID) -> list[Movie]: + stmt = ( + select(Movie) + .join(MovieCompany, MovieCompany.movie_id == Movie.id) + .where(MovieCompany.company_id == company_id) + .order_by(Movie.primary_release_date.asc().nulls_last()) + ) + result = await session.execute(stmt) + return list(result.scalars().all()) + + +async def get_company_series(session: AsyncSession, company_id: uuid.UUID) -> list[Series]: + stmt = ( + select(Series) + .join(SeriesCompany, SeriesCompany.series_id == Series.id) + .where(SeriesCompany.company_id == company_id) + .order_by(Series.first_air_date.asc().nulls_last()) + ) + result = await session.execute(stmt) + return list(result.scalars().all()) diff --git a/app/schemas/company.py b/app/schemas/company.py index 17af1cc..c94a23a 100644 --- a/app/schemas/company.py +++ b/app/schemas/company.py @@ -6,6 +6,9 @@ from datetime import datetime from pydantic import BaseModel +from app.schemas.movie import MovieSummaryOut +from app.schemas.tv import SeriesSummaryOut + class CompanyDetailOut(BaseModel): id: str @@ -20,5 +23,8 @@ class CompanyDetailOut(BaseModel): parent_company_id: str | None = None external_ids: dict[str, str] = {} + movies: list[MovieSummaryOut] = [] + series: list[SeriesSummaryOut] = [] + created_at: datetime updated_at: datetime diff --git a/app/services/company_service.py b/app/services/company_service.py index b24f1c1..79bc36d 100644 --- a/app/services/company_service.py +++ b/app/services/company_service.py @@ -12,6 +12,8 @@ from app.repositories import company_repository, polymorphic from app.schemas.common import CompanyBriefOut from app.schemas.company import CompanyDetailOut from app.services.common import primary_artwork_path +from app.services.movie_service import build_movie_summaries +from app.services.tv_service import build_series_summaries class CompanyNotFoundError(Exception): @@ -33,6 +35,8 @@ async def get_company_detail(session: AsyncSession, company_aurora_id: str) -> C logos = await polymorphic.get_artwork(session, "company", company_id, ArtworkType.LOGO) external_ids = await polymorphic.get_external_ids(session, "company", company_id) + movies = await company_repository.get_company_movies(session, company_id) + series = await company_repository.get_company_series(session, company_id) return CompanyDetailOut( id=aurora_id("company", str(company.id)), @@ -49,6 +53,8 @@ async def get_company_detail(session: AsyncSession, company_aurora_id: str) -> C else None ), external_ids=external_ids, + movies=await build_movie_summaries(session, [m.id for m in movies]), + series=await build_series_summaries(session, [s.id for s in series]), created_at=company.created_at, updated_at=company.updated_at, ) diff --git a/docs/api.md b/docs/api.md index 4633716..4b5d79c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -160,6 +160,13 @@ member movies purely by external-id lookup, the same never-fabricate-a-stub rule `recommendations` follows, so a franchise fills in as more of its movies get ingested rather than all at once. +Company detail also carries `movies` and `series` lists — everything that +company is credited on (any role: production, distribution, network), +ordered by release/first-air date. No never-fabricate-a-stub caveat here: +a `MovieCompany`/`SeriesCompany` row is only ever created as a side effect +of actually ingesting that movie or series, so there's no separate +"company's known filmography" TMDb ever needs linking after the fact. + ## Administrative (section 17: "separately protected") Require header `X-API-Key: `. Never exposed to diff --git a/docs/database.md b/docs/database.md index 18dd913..7baed7a 100644 --- a/docs/database.md +++ b/docs/database.md @@ -57,7 +57,12 @@ else carries `job`. Series-level "Creator" credits and episode A single `companies` table serves production companies, studios, networks, and distributors, distinguished by `company_type` (section 8) — a TV network and a movie studio have the same shape (name, logo, country, parent -company), so a separate `Network` entity would just duplicate columns. +company), so a separate `Network` entity would just duplicate columns. A +company's own filmography (`GET /api/v1/companies/{id}`'s `movies`/`series` +lists) is read straight off `movie_companies`/`series_companies` — no +separate sync step, since those rows only ever exist as a side effect of +ingesting the movie or series itself (`app/repositories/company_repository.py`). + `collections` (franchises) are first-class, with their own artwork and `collection_relations` for shared-universe links between collections (section 9). A collection starts as a bare name-only stub the first time diff --git a/tests/integration/test_company_filmography.py b/tests/integration/test_company_filmography.py new file mode 100644 index 0000000..331ec9b --- /dev/null +++ b/tests/integration/test_company_filmography.py @@ -0,0 +1,94 @@ +"""Integration test for company filmography (section 17): GET /companies/{id} +listing the movies/series a studio/network has produced, the same pattern +collections use for their member movies. Exercised through +company_service.get_company_detail against real PostgreSQL.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.ids import aurora_id +from app.models.company import Company +from app.services import company_service +from app.services.ingestion.movie_ingestion import ingest_movie +from app.services.ingestion.series_ingestion import ingest_series +from tests.integration.test_movie_ingestion import FakeTMDbProvider +from tests.integration.test_series_ingestion import FakeTMDbSeriesProvider + +_SHARED_STUDIO = {"id": 555, "name": "Shared Studio", "origin_country": "US"} + + +def _movie_payload(**overrides: Any) -> dict[str, Any]: + base = { + "id": 9200, + "title": "Untitled", + "status": "Released", + "production_companies": [_SHARED_STUDIO], + "release_date": "2020-01-01", + } + base.update(overrides) + return base + + +def _series_payload(**overrides: Any) -> dict[str, Any]: + base = { + "id": 9200, + "name": "Untitled", + "status": "Ended", + "production_companies": [_SHARED_STUDIO], + "first_air_date": "2020-01-01", + } + base.update(overrides) + return base + + +@pytest.mark.asyncio +async def test_company_detail_lists_its_movies_and_series_by_release_date( + db_session: AsyncSession, +): + later_movie = _movie_payload(id=9201, title="Studio Movie 1", release_date="2021-01-01") + earlier_movie = _movie_payload(id=9202, title="Studio Movie 2", release_date="2019-01-01") + show = _series_payload(id=9203, name="Studio Show", first_air_date="2020-06-01") + + await ingest_movie(db_session, FakeTMDbProvider(later_movie), "9201") + await ingest_movie(db_session, FakeTMDbProvider(earlier_movie), "9202") + await ingest_series(db_session, FakeTMDbSeriesProvider(show, {}), "9203") + await db_session.commit() + + company = ( + await db_session.execute(select(Company).where(Company.name == "Shared Studio")) + ).scalar_one() + + detail = await company_service.get_company_detail( + db_session, aurora_id("company", str(company.id)) + ) + + # Ordered by release date ascending, not ingestion order. + assert [m.title for m in detail.movies] == ["Studio Movie 2", "Studio Movie 1"] + assert [s.title for s in detail.series] == ["Studio Show"] + + +@pytest.mark.asyncio +async def test_company_detail_series_list_is_empty_for_a_movie_only_studio( + db_session: AsyncSession, +): + movie = _movie_payload( + id=9204, title="Lone Movie", production_companies=[{"id": 556, "name": "Solo Studio"}] + ) + await ingest_movie(db_session, FakeTMDbProvider(movie), "9204") + await db_session.commit() + + company = ( + await db_session.execute(select(Company).where(Company.name == "Solo Studio")) + ).scalar_one() + + detail = await company_service.get_company_detail( + db_session, aurora_id("company", str(company.id)) + ) + + assert len(detail.movies) == 1 + assert detail.series == []