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.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(
+24 -1
View File
@@ -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]
@@ -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(
+28 -1
View File
@@ -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", [])
]
+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.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
+5 -2
View File
@@ -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.
+25
View File
@@ -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")
+27 -3
View File
@@ -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