diff --git a/app/services/ingestion/movie_ingestion.py b/app/services/ingestion/movie_ingestion.py index 98f7974..625de39 100644 --- a/app/services/ingestion/movie_ingestion.py +++ b/app/services/ingestion/movie_ingestion.py @@ -32,7 +32,7 @@ from app.repositories import polymorphic from app.repositories import reference_repository as refs from app.repositories.provider_repository import get_or_create_provider, upsert_provider_record from app.services.metadata.matcher import match_entity -from app.services.metadata.normalizer import NormalizedMovie, normalize_movie +from app.services.metadata.normalizer import NormalizedMovie, company_logo_artwork, normalize_movie logger = get_logger(__name__) @@ -68,6 +68,15 @@ async def _replace_movie_companies( country_code=country_code, ) session.add(MovieCompany(movie_id=movie_id, company_id=company.id, role=ref.role)) + logo = company_logo_artwork(ref.logo_path) + if logo is not None: + await polymorphic.sync_artwork( + session, + entity_type="company", + entity_id=company.id, + provider_id=provider_id, + artwork_list=[logo], + ) async def _replace_movie_credits( diff --git a/app/services/ingestion/runner.py b/app/services/ingestion/runner.py index 59c203c..d57f468 100644 --- a/app/services/ingestion/runner.py +++ b/app/services/ingestion/runner.py @@ -19,6 +19,7 @@ from app.models.enums import IngestionJobStatus from app.models.provider import IngestionJob from app.providers.base import MetadataProvider from app.providers.tmdb import TMDbProvider +from app.repositories.movie_repository import get_movie_companies from app.repositories.polymorphic import get_primary_artwork_ids from app.repositories.provider_repository import ( create_ingestion_job, @@ -28,7 +29,7 @@ from app.repositories.provider_repository import ( mark_job_running, mark_job_succeeded, ) -from app.repositories.tv_repository import get_seasons_for_series +from app.repositories.tv_repository import get_seasons_for_series, get_series_companies from app.services.ingestion.movie_ingestion import ingest_movie from app.services.ingestion.person_ingestion import ingest_person from app.services.ingestion.series_ingestion import ingest_season, ingest_series @@ -185,3 +186,25 @@ async def get_primary_artwork_for_download( async with session_scope() as session: return await get_primary_artwork_ids(session, entity_type, entity_ids) + + +async def get_movie_company_ids(movie_id: uuid.UUID) -> list[uuid.UUID]: + """Companies (production companies) a just-ingested movie references, + for cascading their logo into a download too — see + app/workers/tasks.py:_cascade_artwork_downloads. Shared across many + movies, so this fires on every movie referencing a given company, but + download_artwork is idempotent (skips once local_path is set).""" + + async with session_scope() as session: + companies = await get_movie_companies(session, movie_id) + return [company.id for company, _role in companies] + + +async def get_series_company_ids(series_id: uuid.UUID) -> list[uuid.UUID]: + """Same as get_movie_company_ids, but for a series' companies *and* + networks (get_series_companies with no company_type filter returns + both).""" + + async with session_scope() as session: + companies = await get_series_companies(session, series_id) + return [company.id for company, _role in companies] diff --git a/app/services/ingestion/series_ingestion.py b/app/services/ingestion/series_ingestion.py index 633ad0f..84d9cd5 100644 --- a/app/services/ingestion/series_ingestion.py +++ b/app/services/ingestion/series_ingestion.py @@ -34,6 +34,7 @@ from app.repositories.provider_repository import get_or_create_provider, upsert_ from app.services.metadata.matcher import match_entity from app.services.metadata.normalizer import ( NormalizedSeries, + company_logo_artwork, normalize_season, normalize_series, ) @@ -94,6 +95,15 @@ async def _replace_series_companies( country_code=country_code, ) session.add(SeriesCompany(series_id=series_id, company_id=company.id, role=role)) + logo = company_logo_artwork(ref.logo_path) + if logo is not None: + await polymorphic.sync_artwork( + session, + entity_type="company", + entity_id=company.id, + provider_id=provider_id, + artwork_list=[logo], + ) async def _replace_series_credits( diff --git a/app/services/metadata/normalizer.py b/app/services/metadata/normalizer.py index 38aee05..6d6374e 100644 --- a/app/services/metadata/normalizer.py +++ b/app/services/metadata/normalizer.py @@ -54,6 +54,7 @@ class NormalizedCompanyRef: name: str country_code: str | None = None role: str = "production" + logo_path: str | None = None # TMDb's raw path, e.g. "/logo.png" — not a full URL yet @dataclass @@ -74,6 +75,27 @@ class NormalizedArtwork: is_primary: bool = False +def company_logo_artwork(logo_path: str | None) -> NormalizedArtwork | None: + """A single-item artwork entry for a company/network's TMDb logo_path, + or None if it doesn't have one. Shared by movie_ingestion.py and + series_ingestion.py so the "how do we turn a relative TMDb path into a + real image URL" logic stays in one place (this module), rather than the + ingestion layer needing to know about TMDb's CDN itself.""" + + if not logo_path: + return None + return NormalizedArtwork( + artwork_type="logo", + url=_image_url_required(logo_path), + provider_asset_id=logo_path, + width=None, + height=None, + aspect_ratio=None, + language=None, + is_primary=True, # a company has exactly one logo ingested today + ) + + @dataclass class NormalizedVideo: video_type: str @@ -173,6 +195,7 @@ def normalize_movie(raw: dict) -> NormalizedMovie: external_id=str(pc["id"]), name=pc["name"], country_code=pc.get("origin_country") or None, + logo_path=pc.get("logo_path"), ) for pc in raw.get("production_companies", []) ] @@ -464,7 +487,10 @@ def normalize_series(raw: dict) -> NormalizedSeries: networks = [ NormalizedCompanyRef( - external_id=str(n["id"]), name=n["name"], country_code=n.get("origin_country") or None + external_id=str(n["id"]), + name=n["name"], + country_code=n.get("origin_country") or None, + logo_path=n.get("logo_path"), ) for n in raw.get("networks", []) ] @@ -473,6 +499,7 @@ def normalize_series(raw: dict) -> NormalizedSeries: external_id=str(pc["id"]), name=pc["name"], country_code=pc.get("origin_country") or None, + logo_path=pc.get("logo_path"), ) for pc in raw.get("production_companies", []) ] diff --git a/app/workers/tasks.py b/app/workers/tasks.py index 1cd941c..2e51010 100644 --- a/app/workers/tasks.py +++ b/app/workers/tasks.py @@ -22,8 +22,10 @@ from app.core.logging import get_logger from app.services.artwork.download import download_artwork from app.services.artwork.storage import get_artwork_storage from app.services.ingestion.runner import ( + get_movie_company_ids, get_or_create_ingestion_job, get_primary_artwork_for_download, + get_series_company_ids, get_series_season_ids, get_series_season_numbers, run_movie_ingestion, @@ -52,6 +54,7 @@ async def _cascade_artwork_downloads( async def ingest_movie_task(ctx: dict[str, Any], job_id: str, external_id: str) -> str: movie_id = await run_movie_ingestion(uuid.UUID(job_id), external_id) await _cascade_artwork_downloads(ctx, "movie", [movie_id]) + await _cascade_artwork_downloads(ctx, "company", await get_movie_company_ids(movie_id)) return str(movie_id) @@ -70,6 +73,7 @@ async def ingest_series_task( # creates, independent of include_seasons (which only controls whether # full per-season episode data gets fetched) -- so this always runs. await _cascade_artwork_downloads(ctx, "season", await get_series_season_ids(series_id)) + await _cascade_artwork_downloads(ctx, "company", await get_series_company_ids(series_id)) if include_seasons: # Fan out one ingest_season job per season this series has, using diff --git a/docs/database.md b/docs/database.md index 6fb9151..11a5b4e 100644 --- a/docs/database.md +++ b/docs/database.md @@ -82,8 +82,11 @@ automatically once it has. Only artwork marked `is_primary` gets downloaded automatically — the single image the provider designates as *the* image for an entity (TMDb's -`poster_path`/`backdrop_path`/`profile_path`), not the dozens of alternates -also stored. `app/services/ingestion/runner.py:get_primary_artwork_for_download` +`poster_path`/`backdrop_path`/`profile_path`/`logo_path`), not the dozens of +alternates also stored. This includes company/network logos (a company has +exactly one ingested, so it's always primary) — cascaded per-movie/series +since companies aren't independently ingested the way movies/series/people +are. `app/services/ingestion/runner.py:get_primary_artwork_for_download` finds them and `app/workers/tasks.py` enqueues a `download_artwork_task` per id once movie/series/person/season ingestion succeeds — see `docs/architecture.md` for the full flow. diff --git a/tests/integration/test_movie_ingestion.py b/tests/integration/test_movie_ingestion.py index 20910ce..9b94729 100644 --- a/tests/integration/test_movie_ingestion.py +++ b/tests/integration/test_movie_ingestion.py @@ -11,6 +11,8 @@ import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.models.artwork import Artwork +from app.models.company import Company from app.models.credit import Credit from app.models.genre import Genre from app.models.movie import Movie @@ -103,3 +105,26 @@ async def test_ingest_movie_is_idempotent(db_session: AsyncSession): ) # Credits are replaced wholesale on re-sync, not duplicated. assert len(credits_) == 2 # 1 cast + 1 crew from FAKE_MOVIE + + +@pytest.mark.asyncio +async def test_ingest_movie_ingests_company_logo(db_session: AsyncSession): + provider = FakeTMDbProvider(FAKE_MOVIE) + + await ingest_movie(db_session, provider, "603") + await db_session.commit() + + company = ( + await db_session.execute( + select(Company).where(Company.name == "Village Roadshow Pictures") + ) + ).scalar_one() + + logo = ( + await db_session.execute( + select(Artwork).where(Artwork.entity_type == "company", Artwork.entity_id == company.id) + ) + ).scalar_one() + assert logo.artwork_type.value == "logo" + assert logo.is_primary is True + assert logo.original_url.endswith("/village-roadshow-logo.png") diff --git a/tests/unit/test_normalizer.py b/tests/unit/test_normalizer.py index d341a61..dd14bdb 100644 --- a/tests/unit/test_normalizer.py +++ b/tests/unit/test_normalizer.py @@ -32,7 +32,12 @@ FAKE_MOVIE = { "imdb_id": "tt0133093", "genres": [{"id": 28, "name": "Action"}, {"id": 878, "name": "Science Fiction"}], "production_companies": [ - {"id": 79, "name": "Village Roadshow Pictures", "origin_country": "US"} + { + "id": 79, + "name": "Village Roadshow Pictures", + "origin_country": "US", + "logo_path": "/village-roadshow-logo.png", + } ], "poster_path": "/poster.jpg", "backdrop_path": "/backdrop.jpg", @@ -160,6 +165,17 @@ def test_normalize_movie_marks_exactly_one_primary_poster_and_backdrop(): assert primary_backdrops[0].provider_asset_id == "/backdrop.jpg" +def test_normalize_movie_maps_company_logo_path(): + movie = normalize_movie(FAKE_MOVIE) + assert movie.companies[0].logo_path == "/village-roadshow-logo.png" + + +def test_normalize_movie_company_without_logo_is_none(): + minimal = {**FAKE_MOVIE, "production_companies": [{"id": 1, "name": "No Logo Studio"}]} + movie = normalize_movie(minimal) + assert movie.companies[0].logo_path is None + + def test_normalize_movie_external_ids_never_include_tmdb_itself(): # TMDb's own id is handled separately by the ingestion pipeline via # `normalized.external_id`, not folded into `external_ids` (section 3: @@ -224,9 +240,9 @@ FAKE_SERIES = { "vote_count": 15000, "genres": [{"id": 18, "name": "Drama"}, {"id": 80, "name": "Crime"}], "created_by": [{"id": 66633, "name": "Vince Gilligan", "gender": 2}], - "networks": [{"id": 174, "name": "AMC", "origin_country": "US"}], + "networks": [{"id": 174, "name": "AMC", "origin_country": "US", "logo_path": "/amc-logo.png"}], "production_companies": [ - {"id": 2605, "name": "Gran Via Productions", "origin_country": "US"} + {"id": 2605, "name": "Gran Via Productions", "origin_country": "US", "logo_path": None} ], "poster_path": "/poster.jpg", "backdrop_path": "/backdrop.jpg", @@ -323,6 +339,14 @@ def test_normalize_series_maps_networks_vs_production_companies(): assert [c.name for c in series.companies] == ["Gran Via Productions"] +def test_normalize_series_maps_network_logo_path(): + series = normalize_series(FAKE_SERIES) + assert series.networks[0].logo_path == "/amc-logo.png" + # Gran Via's fixture entry has no logo_path at all -- must come through + # as None, not KeyError. + assert series.companies[0].logo_path is None + + def test_normalize_series_maps_creators_separately_from_crew(): series = normalize_series(FAKE_SERIES) assert len(series.creators) == 1