Ingest and self-host company/network logos
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s

CompanyBriefOut.logo_path and CompanyDetailOut.logo_path have existed
in the API schemas since Core 0.1's first pass, but nothing ever
actually populated them -- TMDb's production_companies/networks
entries carry a logo_path field that the normalizer simply never read,
so every response has silently returned null there.

- NormalizedCompanyRef now captures logo_path from both movie
  production_companies and series networks/production_companies.
- normalizer.company_logo_artwork() builds the single-image artwork
  entry for it (a company has exactly one logo ingested, so it's
  always is_primary) -- centralized there rather than duplicated in
  both ingestion modules, matching how movie/series artwork URLs are
  already built in one place.
- movie_ingestion.py and series_ingestion.py sync it via the same
  sync_artwork() the rest of section 10's self-hosting already uses,
  so it gets the same re-ingestion-safe upsert behavior for free.
- tasks.py cascades a download_artwork_task per referenced company
  after movie/series ingestion, same fan-out pattern as
  movie/series/season/person artwork.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
NixReaper
2026-09-05 07:46:30 -05:00
co-authored by Claude Sonnet 5
parent 69ce41d54f
commit c03a75989d
8 changed files with 133 additions and 8 deletions
+10 -1
View File
@@ -32,7 +32,7 @@ from app.repositories import polymorphic
from app.repositories import reference_repository as refs from app.repositories import reference_repository as refs
from app.repositories.provider_repository import get_or_create_provider, upsert_provider_record 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.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__) logger = get_logger(__name__)
@@ -68,6 +68,15 @@ async def _replace_movie_companies(
country_code=country_code, country_code=country_code,
) )
session.add(MovieCompany(movie_id=movie_id, company_id=company.id, role=ref.role)) 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( async def _replace_movie_credits(
+24 -1
View File
@@ -19,6 +19,7 @@ from app.models.enums import IngestionJobStatus
from app.models.provider import IngestionJob from app.models.provider import IngestionJob
from app.providers.base import MetadataProvider from app.providers.base import MetadataProvider
from app.providers.tmdb import TMDbProvider 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.polymorphic import get_primary_artwork_ids
from app.repositories.provider_repository import ( from app.repositories.provider_repository import (
create_ingestion_job, create_ingestion_job,
@@ -28,7 +29,7 @@ from app.repositories.provider_repository import (
mark_job_running, mark_job_running,
mark_job_succeeded, 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.movie_ingestion import ingest_movie
from app.services.ingestion.person_ingestion import ingest_person from app.services.ingestion.person_ingestion import ingest_person
from app.services.ingestion.series_ingestion import ingest_season, ingest_series 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: async with session_scope() as session:
return await get_primary_artwork_ids(session, entity_type, entity_ids) 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]
@@ -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.matcher import match_entity
from app.services.metadata.normalizer import ( from app.services.metadata.normalizer import (
NormalizedSeries, NormalizedSeries,
company_logo_artwork,
normalize_season, normalize_season,
normalize_series, normalize_series,
) )
@@ -94,6 +95,15 @@ async def _replace_series_companies(
country_code=country_code, country_code=country_code,
) )
session.add(SeriesCompany(series_id=series_id, company_id=company.id, role=role)) 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( async def _replace_series_credits(
+28 -1
View File
@@ -54,6 +54,7 @@ class NormalizedCompanyRef:
name: str name: str
country_code: str | None = None country_code: str | None = None
role: str = "production" role: str = "production"
logo_path: str | None = None # TMDb's raw path, e.g. "/logo.png" — not a full URL yet
@dataclass @dataclass
@@ -74,6 +75,27 @@ class NormalizedArtwork:
is_primary: bool = False 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 @dataclass
class NormalizedVideo: class NormalizedVideo:
video_type: str video_type: str
@@ -173,6 +195,7 @@ def normalize_movie(raw: dict) -> NormalizedMovie:
external_id=str(pc["id"]), external_id=str(pc["id"]),
name=pc["name"], name=pc["name"],
country_code=pc.get("origin_country") or None, country_code=pc.get("origin_country") or None,
logo_path=pc.get("logo_path"),
) )
for pc in raw.get("production_companies", []) for pc in raw.get("production_companies", [])
] ]
@@ -464,7 +487,10 @@ def normalize_series(raw: dict) -> NormalizedSeries:
networks = [ networks = [
NormalizedCompanyRef( 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", []) for n in raw.get("networks", [])
] ]
@@ -473,6 +499,7 @@ def normalize_series(raw: dict) -> NormalizedSeries:
external_id=str(pc["id"]), external_id=str(pc["id"]),
name=pc["name"], name=pc["name"],
country_code=pc.get("origin_country") or None, country_code=pc.get("origin_country") or None,
logo_path=pc.get("logo_path"),
) )
for pc in raw.get("production_companies", []) for pc in raw.get("production_companies", [])
] ]
+4
View File
@@ -22,8 +22,10 @@ from app.core.logging import get_logger
from app.services.artwork.download import download_artwork from app.services.artwork.download import download_artwork
from app.services.artwork.storage import get_artwork_storage from app.services.artwork.storage import get_artwork_storage
from app.services.ingestion.runner import ( from app.services.ingestion.runner import (
get_movie_company_ids,
get_or_create_ingestion_job, get_or_create_ingestion_job,
get_primary_artwork_for_download, get_primary_artwork_for_download,
get_series_company_ids,
get_series_season_ids, get_series_season_ids,
get_series_season_numbers, get_series_season_numbers,
run_movie_ingestion, 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: 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) 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, "movie", [movie_id])
await _cascade_artwork_downloads(ctx, "company", await get_movie_company_ids(movie_id))
return str(movie_id) return str(movie_id)
@@ -70,6 +73,7 @@ async def ingest_series_task(
# creates, independent of include_seasons (which only controls whether # creates, independent of include_seasons (which only controls whether
# full per-season episode data gets fetched) -- so this always runs. # 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, "season", await get_series_season_ids(series_id))
await _cascade_artwork_downloads(ctx, "company", await get_series_company_ids(series_id))
if include_seasons: if include_seasons:
# Fan out one ingest_season job per season this series has, using # Fan out one ingest_season job per season this series has, using
+5 -2
View File
@@ -82,8 +82,11 @@ automatically once it has.
Only artwork marked `is_primary` gets downloaded automatically — the single Only artwork marked `is_primary` gets downloaded automatically — the single
image the provider designates as *the* image for an entity (TMDb's image the provider designates as *the* image for an entity (TMDb's
`poster_path`/`backdrop_path`/`profile_path`), not the dozens of alternates `poster_path`/`backdrop_path`/`profile_path`/`logo_path`), not the dozens of
also stored. `app/services/ingestion/runner.py:get_primary_artwork_for_download` 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 finds them and `app/workers/tasks.py` enqueues a `download_artwork_task` per
id once movie/series/person/season ingestion succeeds — see id once movie/series/person/season ingestion succeeds — see
`docs/architecture.md` for the full flow. `docs/architecture.md` for the full flow.
+25
View File
@@ -11,6 +11,8 @@ import pytest
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.credit import Credit
from app.models.genre import Genre from app.models.genre import Genre
from app.models.movie import Movie 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. # Credits are replaced wholesale on re-sync, not duplicated.
assert len(credits_) == 2 # 1 cast + 1 crew from FAKE_MOVIE 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")
+27 -3
View File
@@ -32,7 +32,12 @@ FAKE_MOVIE = {
"imdb_id": "tt0133093", "imdb_id": "tt0133093",
"genres": [{"id": 28, "name": "Action"}, {"id": 878, "name": "Science Fiction"}], "genres": [{"id": 28, "name": "Action"}, {"id": 878, "name": "Science Fiction"}],
"production_companies": [ "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", "poster_path": "/poster.jpg",
"backdrop_path": "/backdrop.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" 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(): def test_normalize_movie_external_ids_never_include_tmdb_itself():
# TMDb's own id is handled separately by the ingestion pipeline via # TMDb's own id is handled separately by the ingestion pipeline via
# `normalized.external_id`, not folded into `external_ids` (section 3: # `normalized.external_id`, not folded into `external_ids` (section 3:
@@ -224,9 +240,9 @@ FAKE_SERIES = {
"vote_count": 15000, "vote_count": 15000,
"genres": [{"id": 18, "name": "Drama"}, {"id": 80, "name": "Crime"}], "genres": [{"id": 18, "name": "Drama"}, {"id": 80, "name": "Crime"}],
"created_by": [{"id": 66633, "name": "Vince Gilligan", "gender": 2}], "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": [ "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", "poster_path": "/poster.jpg",
"backdrop_path": "/backdrop.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"] 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(): def test_normalize_series_maps_creators_separately_from_crew():
series = normalize_series(FAKE_SERIES) series = normalize_series(FAKE_SERIES)
assert len(series.creators) == 1 assert len(series.creators) == 1