Add watch providers (where to stream/rent/buy)
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

New TMDb integration end to end: append_to_response "watch/providers",
normalizer parsing grouped by country/monetization type, a WatchProvider
catalog entry (external-id keyed, same get_or_create pattern as Company/
Collection/Person) + polymorphic WatchProviderOffering table (new
migration 0002), ingestion write (including provider logos via the
existing Artwork table), and API exposure -- embedded on movie/series
detail plus a dedicated /watch-providers sub-route, shaped to mirror
TMDb's own response for easy porting.

Note: this data comes from JustWatch via TMDb and carries its own
attribution requirement separate from TMDb's general one -- flagged in
docs/api.md, must be satisfied before showing this in the frontend.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
NixReaper
2026-09-06 04:59:02 -05:00
co-authored by Claude Sonnet 5
parent 74ebad379a
commit 6c95e18aad
21 changed files with 767 additions and 2 deletions
+34
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ids import aurora_id
from app.models.artwork import Artwork
from app.models.credit import Credit
@@ -9,6 +11,8 @@ from app.models.enums import ArtworkType
from app.models.movie import MovieAlternateTitle, ReleaseDate
from app.models.tv import SeriesAlternateTitle
from app.models.video import Video
from app.models.watch_provider import WatchProviderOffering
from app.repositories import polymorphic
from app.schemas.common import (
AlternateTitleOut,
ArtworkOut,
@@ -19,6 +23,8 @@ from app.schemas.common import (
PersonBriefOut,
ReleaseDateOut,
VideoOut,
WatchProviderOut,
WatchProviderRegionOut,
)
@@ -110,6 +116,34 @@ def pick_certification(release_dates: list[ReleaseDate], *, preferred_country: s
return fallback
async def build_watch_providers_out(
session: AsyncSession, offerings: list[WatchProviderOffering]
) -> dict[str, WatchProviderRegionOut]:
out: dict[str, WatchProviderRegionOut] = {}
logo_cache: dict[object, str | None] = {}
for row in offerings:
region = out.get(row.country_code)
if region is None:
region = WatchProviderRegionOut(link=row.link)
out[row.country_code] = region
elif region.link is None and row.link is not None:
region.link = row.link
wp = row.watch_provider
if wp.id not in logo_cache:
logos = await polymorphic.get_artwork(session, "watch_provider", wp.id, ArtworkType.LOGO)
logo_cache[wp.id] = primary_artwork_path(logos, ArtworkType.LOGO)
entry = WatchProviderOut(
id=aurora_id("watch_provider", str(wp.id)),
name=wp.name,
logo_path=logo_cache[wp.id],
display_priority=row.display_priority,
)
getattr(region, row.monetization_type).append(entry)
return out
def split_credits(
credits_: list[Credit], profile_paths: dict | None = None
) -> tuple[list[CastCreditOut], list[CrewCreditOut]]:
+17
View File
@@ -271,6 +271,23 @@ async def ingest_movie(
await polymorphic.sync_release_dates(
session, entity_type="movie", entity_id=movie.id, release_dates=normalized.release_dates
)
touched_watch_providers = await polymorphic.sync_watch_providers(
session,
entity_type="movie",
entity_id=movie.id,
tmdb_provider_id=tmdb_provider.id,
offerings=normalized.watch_providers,
)
for watch_provider, logo_path in touched_watch_providers:
logo = company_logo_artwork(logo_path)
if logo is not None:
await polymorphic.sync_artwork(
session,
entity_type="watch_provider",
entity_id=watch_provider.id,
provider_id=tmdb_provider.id,
artwork_list=[logo],
)
await _link_external_ids(session, movie.id, tmdb_provider.id, normalized)
await session.execute(
@@ -266,6 +266,23 @@ async def ingest_series(
await polymorphic.sync_release_dates(
session, entity_type="series", entity_id=series.id, release_dates=normalized.release_dates
)
touched_watch_providers = await polymorphic.sync_watch_providers(
session,
entity_type="series",
entity_id=series.id,
tmdb_provider_id=tmdb_provider.id,
offerings=normalized.watch_providers,
)
for watch_provider, logo_path in touched_watch_providers:
logo = company_logo_artwork(logo_path)
if logo is not None:
await polymorphic.sync_artwork(
session,
entity_type="watch_provider",
entity_id=watch_provider.id,
provider_id=tmdb_provider.id,
artwork_list=[logo],
)
for stub in normalized.seasons:
season = await tv_repository.get_or_create_season(session, series.id, stub.season_number)
+50
View File
@@ -202,6 +202,52 @@ def _series_release_dates(raw: dict, first_air_date: date | None) -> list[Normal
return out
_MONETIZATION_TYPES = ("flatrate", "rent", "buy", "ads", "free")
@dataclass
class NormalizedWatchProviderRef:
external_id: str
name: str
logo_path: str | None
@dataclass
class NormalizedWatchProviderOffering:
country_code: str
monetization_type: str # one of _MONETIZATION_TYPES
provider: NormalizedWatchProviderRef
display_priority: int | None = None
link: str | None = None
def _watch_providers(raw: dict) -> list[NormalizedWatchProviderOffering]:
# TMDb nests this appended resource under the literal key
# "watch/providers" (slash included), grouped by country then by
# monetization type -- this data comes from JustWatch via TMDb and
# carries its own separate attribution requirement, see docs/api.md.
out: list[NormalizedWatchProviderOffering] = []
results = (raw.get("watch/providers") or {}).get("results") or {}
for country_code, country_block in results.items():
link = country_block.get("link")
for monetization_type in _MONETIZATION_TYPES:
for entry in country_block.get(monetization_type, []):
out.append(
NormalizedWatchProviderOffering(
country_code=country_code,
monetization_type=monetization_type,
provider=NormalizedWatchProviderRef(
external_id=str(entry["provider_id"]),
name=entry["provider_name"],
logo_path=entry.get("logo_path"),
),
display_priority=entry.get("display_priority"),
link=link,
)
)
return out
@dataclass
class NormalizedVideo:
video_type: str
@@ -243,6 +289,7 @@ class NormalizedMovie:
collection: tuple[str, str] | None = None # (external_id, name)
recommendation_external_ids: list[str] = field(default_factory=list)
release_dates: list[NormalizedReleaseDate] = field(default_factory=list)
watch_providers: list[NormalizedWatchProviderOffering] = field(default_factory=list)
@dataclass
@@ -446,6 +493,7 @@ def normalize_movie(raw: dict) -> NormalizedMovie:
collection=(str(collection["id"]), collection["name"]) if collection else None,
recommendation_external_ids=[str(m["id"]) for m in recommendations],
release_dates=_movie_release_dates(raw),
watch_providers=_watch_providers(raw),
)
@@ -595,6 +643,7 @@ class NormalizedSeries:
external_ids: dict[str, str] = field(default_factory=dict)
seasons: list[NormalizedSeasonStub] = field(default_factory=list)
release_dates: list[NormalizedReleaseDate] = field(default_factory=list)
watch_providers: list[NormalizedWatchProviderOffering] = field(default_factory=list)
def normalize_series(raw: dict) -> NormalizedSeries:
@@ -776,6 +825,7 @@ def normalize_series(raw: dict) -> NormalizedSeries:
external_ids=external_ids,
seasons=seasons,
release_dates=_series_release_dates(raw, first_air_date),
watch_providers=_watch_providers(raw),
)
+17
View File
@@ -19,8 +19,10 @@ from app.schemas.movie import (
MovieRecommendationsOut,
MovieSummaryOut,
MovieVideosOut,
MovieWatchProvidersOut,
)
from app.services.common import (
build_watch_providers_out,
pick_certification,
primary_artwork_path,
split_credits,
@@ -60,6 +62,7 @@ async def get_movie_detail(session: AsyncSession, movie_aurora_id: str) -> Movie
artwork = await polymorphic.get_artwork(session, "movie", movie_id)
videos = await polymorphic.get_videos(session, "movie", movie_id)
release_dates = await polymorphic.get_release_dates(session, "movie", movie_id)
watch_provider_rows = await polymorphic.get_watch_providers(session, "movie", movie_id)
external_ids = await polymorphic.get_external_ids(session, "movie", movie_id)
cast, crew = split_credits(credits_)
@@ -114,6 +117,7 @@ async def get_movie_detail(session: AsyncSession, movie_aurora_id: str) -> Movie
images=to_images_out(artwork),
videos=[to_video_out(v) for v in videos],
release_dates=[to_release_date_out(rd) for rd in release_dates],
watch_providers=await build_watch_providers_out(session, watch_provider_rows),
external_ids=external_ids,
created_at=movie.created_at,
updated_at=movie.updated_at,
@@ -148,6 +152,19 @@ async def get_movie_videos(session: AsyncSession, movie_aurora_id: str) -> Movie
return MovieVideosOut(id=aurora_id("movie", str(movie.id)), videos=[to_video_out(v) for v in videos])
async def get_movie_watch_providers(
session: AsyncSession, movie_aurora_id: str
) -> MovieWatchProvidersOut:
movie_id = await _resolve_movie_id(movie_aurora_id)
movie = await movie_repository.get_movie_by_id(session, movie_id)
if movie is None:
raise MovieNotFoundError(movie_aurora_id)
rows = await polymorphic.get_watch_providers(session, "movie", movie_id)
return MovieWatchProvidersOut(
id=aurora_id("movie", str(movie.id)), results=await build_watch_providers_out(session, rows)
)
async def get_movie_recommendations(
session: AsyncSession, movie_aurora_id: str
) -> MovieRecommendationsOut:
+17
View File
@@ -24,8 +24,10 @@ from app.schemas.tv import (
SeriesSeasonsOut,
SeriesSummaryOut,
SeriesVideosOut,
SeriesWatchProvidersOut,
)
from app.services.common import (
build_watch_providers_out,
pick_certification,
primary_artwork_path,
split_credits,
@@ -109,6 +111,7 @@ async def get_series_detail(session: AsyncSession, series_aurora_id: str) -> Ser
artwork = await polymorphic.get_artwork(session, "series", series_id)
videos = await polymorphic.get_videos(session, "series", series_id)
release_dates = await polymorphic.get_release_dates(session, "series", series_id)
watch_provider_rows = await polymorphic.get_watch_providers(session, "series", series_id)
external_ids = await polymorphic.get_external_ids(session, "series", series_id)
cast, crew, creators = _split_series_credits(credits_)
@@ -145,6 +148,7 @@ async def get_series_detail(session: AsyncSession, series_aurora_id: str) -> Ser
images=to_images_out(artwork),
videos=[to_video_out(v) for v in videos],
release_dates=[to_release_date_out(rd) for rd in release_dates],
watch_providers=await build_watch_providers_out(session, watch_provider_rows),
external_ids=external_ids,
created_at=series.created_at,
updated_at=series.updated_at,
@@ -193,6 +197,19 @@ async def get_series_videos(session: AsyncSession, series_aurora_id: str) -> Ser
)
async def get_series_watch_providers(
session: AsyncSession, series_aurora_id: str
) -> SeriesWatchProvidersOut:
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)
rows = await polymorphic.get_watch_providers(session, "series", series_id)
return SeriesWatchProvidersOut(
id=aurora_id("series", str(series.id)), results=await build_watch_providers_out(session, rows)
)
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)