From 6c95e18aad0a90280999c427ccdf4c442f2765a4 Mon Sep 17 00:00:00 2001 From: NixReaper Date: Sun, 6 Sep 2026 04:59:02 -0500 Subject: [PATCH] Add watch providers (where to stream/rent/buy) 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 --- app/api/v1/movies.py | 11 ++ app/api/v1/series.py | 11 ++ app/models/__init__.py | 3 + app/models/enums.py | 1 + app/models/watch_provider.py | 58 ++++++ app/providers/tmdb.py | 10 +- app/repositories/polymorphic.py | 95 ++++++++++ app/repositories/reference_repository.py | 47 +++++ app/schemas/common.py | 16 ++ app/schemas/movie.py | 7 + app/schemas/tv.py | 7 + app/services/common.py | 34 ++++ app/services/ingestion/movie_ingestion.py | 17 ++ app/services/ingestion/series_ingestion.py | 17 ++ app/services/metadata/normalizer.py | 50 ++++++ app/services/movie_service.py | 17 ++ app/services/tv_service.py | 17 ++ docs/api.md | 29 +++ migrations/versions/0002_watch_providers.py | 61 +++++++ tests/integration/test_watch_providers.py | 188 ++++++++++++++++++++ tests/unit/test_normalizer.py | 73 ++++++++ 21 files changed, 767 insertions(+), 2 deletions(-) create mode 100644 app/models/watch_provider.py create mode 100644 migrations/versions/0002_watch_providers.py create mode 100644 tests/integration/test_watch_providers.py diff --git a/app/api/v1/movies.py b/app/api/v1/movies.py index 9dd62e5..aafbe97 100644 --- a/app/api/v1/movies.py +++ b/app/api/v1/movies.py @@ -19,6 +19,7 @@ from app.schemas.movie import ( MovieListOut, MovieRecommendationsOut, MovieVideosOut, + MovieWatchProvidersOut, ) from app.services import movie_service from app.services.cache import build_key @@ -129,3 +130,13 @@ async def get_movie_recommendations( return await movie_service.get_movie_recommendations(session, aurora_id) except movie_service.MovieNotFoundError as exc: raise not_found("movie", aurora_id) from exc + + +@router.get("/{aurora_id}/watch-providers", response_model=MovieWatchProvidersOut) +async def get_movie_watch_providers( + aurora_id: str, session: AsyncSession = Depends(get_db) +) -> MovieWatchProvidersOut: + try: + return await movie_service.get_movie_watch_providers(session, aurora_id) + except movie_service.MovieNotFoundError as exc: + raise not_found("movie", aurora_id) from exc diff --git a/app/api/v1/series.py b/app/api/v1/series.py index 7226031..6b3e935 100644 --- a/app/api/v1/series.py +++ b/app/api/v1/series.py @@ -19,6 +19,7 @@ from app.schemas.tv import ( SeriesListOut, SeriesSeasonsOut, SeriesVideosOut, + SeriesWatchProvidersOut, ) from app.services import tv_service from app.services.cache import build_key @@ -109,3 +110,13 @@ async def get_series_videos( return await tv_service.get_series_videos(session, aurora_id) except tv_service.SeriesNotFoundError as exc: raise not_found("series", aurora_id) from exc + + +@router.get("/{aurora_id}/watch-providers", response_model=SeriesWatchProvidersOut) +async def get_series_watch_providers( + aurora_id: str, session: AsyncSession = Depends(get_db) +) -> SeriesWatchProvidersOut: + try: + return await tv_service.get_series_watch_providers(session, aurora_id) + except tv_service.SeriesNotFoundError as exc: + raise not_found("series", aurora_id) from exc diff --git a/app/models/__init__.py b/app/models/__init__.py index 902300a..db2a19e 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -33,6 +33,7 @@ from app.models.tv import ( SeriesSpokenLanguage, ) from app.models.video import Video +from app.models.watch_provider import WatchProvider, WatchProviderOffering __all__ = [ "Base", @@ -72,4 +73,6 @@ __all__ = [ "Season", "Episode", "Video", + "WatchProvider", + "WatchProviderOffering", ] diff --git a/app/models/enums.py b/app/models/enums.py index 899bf8a..5812e27 100644 --- a/app/models/enums.py +++ b/app/models/enums.py @@ -61,6 +61,7 @@ class EntityType(enum.StrEnum): PERSON = "person" COMPANY = "company" COLLECTION = "collection" + WATCH_PROVIDER = "watch_provider" class IngestionJobStatus(enum.StrEnum): diff --git a/app/models/watch_provider.py b/app/models/watch_provider.py new file mode 100644 index 0000000..72a7596 --- /dev/null +++ b/app/models/watch_provider.py @@ -0,0 +1,58 @@ +"""Where-to-watch offerings (JustWatch data via TMDb's watch/providers +endpoint) -- section 17, additive. + +Attribution note: TMDb's own terms require this specific data to be +sourced back to JustWatch, not just TMDb itself -- see docs/api.md.""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import ForeignKey, Index, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, UUIDPrimaryKeyMixin + + +class WatchProvider(Base, UUIDPrimaryKeyMixin): + """A streaming/rental/purchase service catalog entry (Netflix, Apple TV, + etc.) -- TMDb's own provider_id links here via ExternalID, same pattern + as Company/Collection/Person. Its logo lives in the shared polymorphic + Artwork table (entity_type="watch_provider"), same as a Company logo.""" + + __tablename__ = "watch_providers" + + name: Mapped[str] = mapped_column(String(200), nullable=False) + + +class WatchProviderOffering(Base, UUIDPrimaryKeyMixin): + """One (entity, country, monetization type, provider) row -- where a + movie/series can be streamed/rented/bought/watched free/with ads in a + given region. Polymorphic across movie/series, same shape as + ReleaseDate.""" + + __tablename__ = "watch_provider_offerings" + __table_args__ = ( + Index("ix_watch_provider_offerings_entity", "entity_type", "entity_id"), + ) + + entity_type: Mapped[str] = mapped_column(String(30), nullable=False) + entity_id: Mapped[uuid.UUID] = mapped_column(nullable=False) + + country_code: Mapped[str] = mapped_column(ForeignKey("countries.code"), nullable=False) + # "flatrate" (subscription) | "rent" | "buy" | "ads" | "free" + monetization_type: Mapped[str] = mapped_column(String(20), nullable=False) + watch_provider_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("watch_providers.id", ondelete="CASCADE"), nullable=False + ) + display_priority: Mapped[int | None] = mapped_column(Integer, nullable=True) + # TMDb/JustWatch's deep link for this (entity, country) -- identical + # across every provider row for the same country, denormalized here + # rather than a separate link-per-country table since it's cheap and + # keeps the read path to a single table. + link: Mapped[str | None] = mapped_column(Text, nullable=True) + + watch_provider: Mapped[WatchProvider] = relationship() + + +__all__ = ["WatchProvider", "WatchProviderOffering"] diff --git a/app/providers/tmdb.py b/app/providers/tmdb.py index 7d86ef6..ca2f515 100644 --- a/app/providers/tmdb.py +++ b/app/providers/tmdb.py @@ -27,8 +27,14 @@ from app.providers.base import ( ProviderRateLimitedError, ) -_MOVIE_APPEND = "credits,images,videos,keywords,alternative_titles,external_ids,release_dates,recommendations" -_SERIES_APPEND = "credits,images,videos,keywords,alternative_titles,external_ids,content_ratings" +_MOVIE_APPEND = ( + "credits,images,videos,keywords,alternative_titles,external_ids,release_dates," + "recommendations,watch/providers" +) +_SERIES_APPEND = ( + "credits,images,videos,keywords,alternative_titles,external_ids,content_ratings," + "watch/providers" +) _PERSON_APPEND = "external_ids,images,combined_credits" _DEFAULT_EXPONENTIAL_WAIT = wait_exponential(multiplier=0.5, max=5) diff --git a/app/repositories/polymorphic.py b/app/repositories/polymorphic.py index 312c8a0..1903221 100644 --- a/app/repositories/polymorphic.py +++ b/app/repositories/polymorphic.py @@ -21,6 +21,7 @@ from app.models.external_id import ExternalID from app.models.movie import ReleaseDate from app.models.provider import Provider from app.models.video import Video +from app.models.watch_provider import WatchProvider, WatchProviderOffering from app.repositories import reference_repository as refs _VALID_ARTWORK_TYPES = {t.value for t in ArtworkType} @@ -39,6 +40,34 @@ class _NormalizedReleaseDateLike(Protocol): note: str | None +class _NormalizedWatchProviderRefLike(Protocol): + """Structural type for + app.services.metadata.normalizer.NormalizedWatchProviderRef.""" + + external_id: str + name: str + logo_path: str | None + + +class _NormalizedWatchProviderOfferingLike(Protocol): + """Structural type for + app.services.metadata.normalizer.NormalizedWatchProviderOffering. The + nested `provider` is a `@property` rather than a plain annotation -- + mypy otherwise treats a Protocol attribute as read-write and requires + invariant (exact) matching, which a genuinely different concrete class + can never satisfy even when structurally identical; a property makes + this member covariant, matching how it's actually used here (read + only, never reassigned).""" + + country_code: str + monetization_type: str + display_priority: int | None + link: str | None + + @property + def provider(self) -> _NormalizedWatchProviderRefLike: ... + + class _NormalizedArtworkLike(Protocol): """Structural type for app.services.metadata.normalizer.NormalizedArtwork — a repository shouldn't import from the services layer, so this @@ -288,3 +317,69 @@ async def sync_release_dates( ) ) await session.flush() + + +async def get_watch_providers( + session: AsyncSession, entity_type: str, entity_id: uuid.UUID +) -> list[WatchProviderOffering]: + stmt = ( + select(WatchProviderOffering) + .options(selectinload(WatchProviderOffering.watch_provider)) + .where( + WatchProviderOffering.entity_type == entity_type, + WatchProviderOffering.entity_id == entity_id, + ) + .order_by( + WatchProviderOffering.country_code.asc(), + WatchProviderOffering.monetization_type.asc(), + WatchProviderOffering.display_priority.asc().nulls_last(), + ) + ) + result = await session.execute(stmt) + return list(result.scalars().all()) + + +async def sync_watch_providers( + session: AsyncSession, + *, + entity_type: str, + entity_id: uuid.UUID, + tmdb_provider_id: uuid.UUID, + offerings: Sequence[_NormalizedWatchProviderOfferingLike], +) -> list[tuple[WatchProvider, str | None]]: + """Replace an entity's watch-provider offerings wholesale, same + delete-then-recreate as release_dates/credits/videos. Returns each + distinct WatchProvider touched paired with its current logo_path so the + caller can sync its logo artwork via company_logo_artwork + sync_artwork + -- this repository layer doesn't import the services layer to build a + NormalizedArtwork itself (see _NormalizedArtworkLike above).""" + + await session.execute( + delete(WatchProviderOffering).where( + WatchProviderOffering.entity_type == entity_type, + WatchProviderOffering.entity_id == entity_id, + ) + ) + touched: dict[uuid.UUID, tuple[WatchProvider, str | None]] = {} + for offering in offerings: + await refs.get_or_create_country(session, offering.country_code, offering.country_code) + watch_provider = await refs.get_or_create_watch_provider( + session, + provider_id=tmdb_provider_id, + external_id=offering.provider.external_id, + name=offering.provider.name, + ) + touched[watch_provider.id] = (watch_provider, offering.provider.logo_path) + session.add( + WatchProviderOffering( + entity_type=entity_type, + entity_id=entity_id, + country_code=offering.country_code, + monetization_type=offering.monetization_type, + watch_provider_id=watch_provider.id, + display_priority=offering.display_priority, + link=offering.link, + ) + ) + await session.flush() + return list(touched.values()) diff --git a/app/repositories/reference_repository.py b/app/repositories/reference_repository.py index db8cf64..ea57100 100644 --- a/app/repositories/reference_repository.py +++ b/app/repositories/reference_repository.py @@ -24,6 +24,7 @@ from app.models.genre import Genre from app.models.keyword import Keyword from app.models.language_country import Certification, Country, Language from app.models.person import Person +from app.models.watch_provider import WatchProvider from app.repositories.concurrency import create_or_recover as _create_or_recover @@ -183,6 +184,52 @@ async def get_or_create_company( return await _create_or_recover(session, _create, _lookup) +async def find_watch_provider_by_external_id( + session: AsyncSession, provider_id: uuid.UUID, external_id: str +) -> WatchProvider | None: + stmt = ( + select(WatchProvider) + .join( + ExternalID, + (ExternalID.entity_id == WatchProvider.id) & (ExternalID.entity_type == "watch_provider"), + ) + .where(ExternalID.provider_id == provider_id, ExternalID.external_id == external_id) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def get_or_create_watch_provider( + session: AsyncSession, *, provider_id: uuid.UUID, external_id: str, name: str +) -> WatchProvider: + async def _lookup() -> WatchProvider | None: + return await find_watch_provider_by_external_id(session, provider_id, external_id) + + existing = await _lookup() + if existing is not None: + if existing.name != name: + existing.name = name + await session.flush() + return existing + + async def _create() -> WatchProvider: + watch_provider = WatchProvider(name=name) + session.add(watch_provider) + await session.flush() + session.add( + ExternalID( + entity_type="watch_provider", + entity_id=watch_provider.id, + provider_id=provider_id, + external_id=external_id, + ) + ) + await session.flush() + return watch_provider + + return await _create_or_recover(session, _create, _lookup) + + async def find_collection_by_external_id( session: AsyncSession, provider_id: uuid.UUID, external_id: str ) -> Collection | None: diff --git a/app/schemas/common.py b/app/schemas/common.py index 846138a..2d3d458 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -139,6 +139,22 @@ class ReleaseDateOut(BaseModel): note: str | None = None +class WatchProviderOut(BaseModel): + id: str + name: str + logo_path: str | None = None + display_priority: int | None = None + + +class WatchProviderRegionOut(BaseModel): + link: str | None = None + flatrate: list[WatchProviderOut] = [] + rent: list[WatchProviderOut] = [] + buy: list[WatchProviderOut] = [] + ads: list[WatchProviderOut] = [] + free: list[WatchProviderOut] = [] + + class ErrorDetail(BaseModel): code: str message: str diff --git a/app/schemas/movie.py b/app/schemas/movie.py index e096d91..2736675 100644 --- a/app/schemas/movie.py +++ b/app/schemas/movie.py @@ -17,6 +17,7 @@ from app.schemas.common import ( KeywordOut, ReleaseDateOut, VideoOut, + WatchProviderRegionOut, ) @@ -66,12 +67,18 @@ class MovieDetailOut(BaseModel): images: ImagesOut = ImagesOut() videos: list[VideoOut] = [] release_dates: list[ReleaseDateOut] = [] + watch_providers: dict[str, WatchProviderRegionOut] = {} external_ids: dict[str, str] = {} created_at: datetime updated_at: datetime +class MovieWatchProvidersOut(BaseModel): + id: str + results: dict[str, WatchProviderRegionOut] = {} + + class MovieCreditsOut(BaseModel): id: str cast: list[CastCreditOut] = [] diff --git a/app/schemas/tv.py b/app/schemas/tv.py index 0a498c3..c41f5b3 100644 --- a/app/schemas/tv.py +++ b/app/schemas/tv.py @@ -16,6 +16,7 @@ from app.schemas.common import ( KeywordOut, ReleaseDateOut, VideoOut, + WatchProviderRegionOut, ) @@ -72,12 +73,18 @@ class SeriesDetailOut(BaseModel): images: ImagesOut = ImagesOut() videos: list[VideoOut] = [] release_dates: list[ReleaseDateOut] = [] + watch_providers: dict[str, WatchProviderRegionOut] = {} external_ids: dict[str, str] = {} created_at: datetime updated_at: datetime +class SeriesWatchProvidersOut(BaseModel): + id: str + results: dict[str, WatchProviderRegionOut] = {} + + class SeriesSeasonsOut(BaseModel): id: str seasons: list[SeasonBriefOut] = [] diff --git a/app/services/common.py b/app/services/common.py index abca94d..63dbe7f 100644 --- a/app/services/common.py +++ b/app/services/common.py @@ -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]]: diff --git a/app/services/ingestion/movie_ingestion.py b/app/services/ingestion/movie_ingestion.py index 8a1e2af..a2086c6 100644 --- a/app/services/ingestion/movie_ingestion.py +++ b/app/services/ingestion/movie_ingestion.py @@ -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( diff --git a/app/services/ingestion/series_ingestion.py b/app/services/ingestion/series_ingestion.py index bccad6e..c0c807a 100644 --- a/app/services/ingestion/series_ingestion.py +++ b/app/services/ingestion/series_ingestion.py @@ -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) diff --git a/app/services/metadata/normalizer.py b/app/services/metadata/normalizer.py index c4d9745..0791d20 100644 --- a/app/services/metadata/normalizer.py +++ b/app/services/metadata/normalizer.py @@ -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), ) diff --git a/app/services/movie_service.py b/app/services/movie_service.py index 0c34c69..ce99d4a 100644 --- a/app/services/movie_service.py +++ b/app/services/movie_service.py @@ -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: diff --git a/app/services/tv_service.py b/app/services/tv_service.py index c0d4cff..8631a41 100644 --- a/app/services/tv_service.py +++ b/app/services/tv_service.py @@ -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) diff --git a/docs/api.md b/docs/api.md index b3226a4..70fc3d3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -49,6 +49,7 @@ GET /api/v1/search?q=the%20matrix | GET | `/api/v1/movies/{aurora_id}/images` | | GET | `/api/v1/movies/{aurora_id}/videos` | | GET | `/api/v1/movies/{aurora_id}/recommendations` | +| GET | `/api/v1/movies/{aurora_id}/watch-providers` | | GET | `/api/v1/movies/now-playing` | | GET | `/api/v1/movies/upcoming` | @@ -131,6 +132,33 @@ ingested: } ``` +`watch_providers` (embedded on movie/series detail as shown above, or its own +`GET .../watch-providers`) is grouped by country then by monetization type — +mirroring TMDb's own `watch/providers` response shape directly, since that's +the shape a client porting from TMDb already expects: + +```json +{ + "id": "aur_movie_01m1q7p1c...", + "results": { + "US": { + "link": "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=US", + "flatrate": [{ "id": "aur_watch_provider_...", "name": "Netflix", "logo_path": "https://...", "display_priority": 0 }], + "rent": [], + "buy": [], + "ads": [], + "free": [] + } + } +} +``` + +**This data comes from JustWatch via TMDb, and carries its own attribution +requirement separate from TMDb's general one** — TMDb's terms require +sourcing watch-provider data back to JustWatch specifically if you display +it, not just crediting TMDb itself. Read TMDb's current terms for the +exact required wording/logo before showing this in the frontend. + `GET /api/v1/movies/now-playing` and `GET /api/v1/movies/upcoming` use the `release_dates` data above rather than `release_date`/`year`: `region` (ISO 3166-1 alpha-2, default `US`), `page`, `page_size` — same @@ -152,6 +180,7 @@ now-playing, soonest for upcoming) rather than appearing twice. | GET | `/api/v1/series/{aurora_id}/credits` | | GET | `/api/v1/series/{aurora_id}/images` | | GET | `/api/v1/series/{aurora_id}/videos` | +| GET | `/api/v1/series/{aurora_id}/watch-providers` | | GET | `/api/v1/seasons/{aurora_id}` | | GET | `/api/v1/episodes/{aurora_id}` | diff --git a/migrations/versions/0002_watch_providers.py b/migrations/versions/0002_watch_providers.py new file mode 100644 index 0000000..07bd9fd --- /dev/null +++ b/migrations/versions/0002_watch_providers.py @@ -0,0 +1,61 @@ +"""watch providers + +Revision ID: 0002_watch_providers +Revises: 0001_initial_schema +Create Date: 2026-09-06 + +Adds the where-to-watch tables (section 17, additive): a WatchProvider +catalog entry (Netflix, Apple TV, etc. -- TMDb's own provider_id links in +via the existing external_ids table, same pattern as Company/Collection/ +Person) and WatchProviderOffering, a polymorphic (entity_type, entity_id) +table like release_dates, one row per (movie/series, country, +monetization type, provider). +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0002_watch_providers" +down_revision: str | None = "0001_initial_schema" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "watch_providers", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("name", sa.String(200), nullable=False), + ) + + op.create_table( + "watch_provider_offerings", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("entity_type", sa.String(30), nullable=False), + sa.Column("entity_id", sa.Uuid(), nullable=False), + sa.Column("country_code", sa.String(2), sa.ForeignKey("countries.code"), nullable=False), + sa.Column("monetization_type", sa.String(20), nullable=False), + sa.Column( + "watch_provider_id", + sa.Uuid(), + sa.ForeignKey("watch_providers.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("display_priority", sa.Integer(), nullable=True), + sa.Column("link", sa.Text(), nullable=True), + ) + op.create_index( + "ix_watch_provider_offerings_entity", + "watch_provider_offerings", + ["entity_type", "entity_id"], + ) + + +def downgrade() -> None: + op.drop_index("ix_watch_provider_offerings_entity", table_name="watch_provider_offerings") + op.drop_table("watch_provider_offerings") + op.drop_table("watch_providers") diff --git a/tests/integration/test_watch_providers.py b/tests/integration/test_watch_providers.py new file mode 100644 index 0000000..232a28e --- /dev/null +++ b/tests/integration/test_watch_providers.py @@ -0,0 +1,188 @@ +"""Integration tests for watch providers (section 17, additive): a new +TMDb integration end to end -- provider request (append_to_response +"watch/providers"), normalizer, ingestion write (WatchProvider + +WatchProviderOffering, plus the provider's logo via the shared polymorphic +Artwork table), repository read, and API exposure (embedded on movie/series +detail, plus a dedicated /watch-providers sub-route). Mirrors +test_release_dates.py's fake provider pattern.""" + +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.artwork import Artwork +from app.models.watch_provider import WatchProvider, WatchProviderOffering +from app.providers.base import MetadataProvider +from app.services import movie_service, tv_service +from app.services.ingestion.movie_ingestion import ingest_movie +from app.services.ingestion.series_ingestion import ingest_series +from tests.unit.test_normalizer import FAKE_MOVIE, FAKE_SERIES + + +class FakeTMDbProvider(MetadataProvider): + slug = "tmdb" + name = "The Movie Database (TMDb)" + + def __init__( + self, movie_payload: dict[str, Any] | None = None, series_payload: dict[str, Any] | None = None + ) -> None: + self._movie_payload = movie_payload + self._series_payload = series_payload + + async def get_movie(self, external_id: str) -> dict[str, Any]: + assert self._movie_payload is not None + return self._movie_payload + + async def get_series(self, external_id: str) -> dict[str, Any]: + assert self._series_payload is not None + return self._series_payload + + async def get_season(self, series_external_id: str, season_number: int) -> dict[str, Any]: + raise NotImplementedError + + async def get_person(self, external_id: str) -> dict[str, Any]: + raise NotImplementedError + + async def get_collection(self, external_id: str) -> dict[str, Any]: + raise NotImplementedError + + async def search_movie(self, query: str) -> list[dict[str, Any]]: + raise NotImplementedError + + +@pytest.mark.asyncio +async def test_ingest_movie_stores_watch_provider_offerings(db_session: AsyncSession): + movie_id = await ingest_movie(db_session, FakeTMDbProvider(movie_payload=FAKE_MOVIE), "603") + await db_session.commit() + + rows = ( + ( + await db_session.execute( + select(WatchProviderOffering).where(WatchProviderOffering.entity_id == movie_id) + ) + ) + .scalars() + .all() + ) + assert len(rows) == 3 + us_flatrate = next( + r for r in rows if r.country_code == "US" and r.monetization_type == "flatrate" + ) + assert us_flatrate.link == "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=US" + + netflix = ( + await db_session.execute(select(WatchProvider).where(WatchProvider.name == "Netflix")) + ).scalar_one() + assert us_flatrate.watch_provider_id == netflix.id + + +@pytest.mark.asyncio +async def test_ingest_movie_dedupes_watch_provider_across_countries(db_session: AsyncSession): + """Netflix appears in both US and GB in FAKE_MOVIE's fixture -- it must + resolve to one WatchProvider row, not two, same external-id-keyed + get_or_create pattern as Company/Collection/Person.""" + + await ingest_movie(db_session, FakeTMDbProvider(movie_payload=FAKE_MOVIE), "603") + await db_session.commit() + + netflix_rows = ( + await db_session.execute(select(WatchProvider).where(WatchProvider.name == "Netflix")) + ).scalars().all() + assert len(netflix_rows) == 1 + + +@pytest.mark.asyncio +async def test_ingest_movie_stores_watch_provider_logo(db_session: AsyncSession): + await ingest_movie(db_session, FakeTMDbProvider(movie_payload=FAKE_MOVIE), "603") + await db_session.commit() + + netflix = ( + await db_session.execute(select(WatchProvider).where(WatchProvider.name == "Netflix")) + ).scalar_one() + logo = ( + await db_session.execute( + select(Artwork).where( + Artwork.entity_type == "watch_provider", Artwork.entity_id == netflix.id + ) + ) + ).scalar_one() + assert logo.original_url.endswith("/netflix.jpg") + + +@pytest.mark.asyncio +async def test_ingest_movie_reingestion_replaces_watch_providers(db_session: AsyncSession): + provider = FakeTMDbProvider(movie_payload=FAKE_MOVIE) + movie_id = await ingest_movie(db_session, provider, "603") + await db_session.commit() + await ingest_movie(db_session, provider, "603") + await db_session.commit() + + rows = ( + ( + await db_session.execute( + select(WatchProviderOffering).where(WatchProviderOffering.entity_id == movie_id) + ) + ) + .scalars() + .all() + ) + assert len(rows) == 3 + + +@pytest.mark.asyncio +async def test_movie_detail_surfaces_watch_providers_grouped_by_country(db_session: AsyncSession): + movie_id = await ingest_movie(db_session, FakeTMDbProvider(movie_payload=FAKE_MOVIE), "603") + await db_session.commit() + + detail = await movie_service.get_movie_detail(db_session, aurora_id("movie", str(movie_id))) + + assert set(detail.watch_providers.keys()) == {"US", "GB"} + us = detail.watch_providers["US"] + assert us.link == "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=US" + assert [p.name for p in us.flatrate] == ["Netflix"] + assert [p.name for p in us.rent] == ["Apple TV"] + assert us.flatrate[0].logo_path.endswith("/netflix.jpg") + + +@pytest.mark.asyncio +async def test_movie_watch_providers_sub_route_matches_embedded_field(db_session: AsyncSession): + movie_id = await ingest_movie(db_session, FakeTMDbProvider(movie_payload=FAKE_MOVIE), "603") + await db_session.commit() + + detail = await movie_service.get_movie_detail(db_session, aurora_id("movie", str(movie_id))) + sub = await movie_service.get_movie_watch_providers(db_session, aurora_id("movie", str(movie_id))) + + assert sub.results == detail.watch_providers + + +@pytest.mark.asyncio +async def test_ingest_series_stores_watch_providers(db_session: AsyncSession): + series_id = await ingest_series(db_session, FakeTMDbProvider(series_payload=FAKE_SERIES), "1396") + await db_session.commit() + + rows = ( + ( + await db_session.execute( + select(WatchProviderOffering).where(WatchProviderOffering.entity_id == series_id) + ) + ) + .scalars() + .all() + ) + assert len(rows) == 1 + + +@pytest.mark.asyncio +async def test_series_detail_surfaces_watch_providers(db_session: AsyncSession): + series_id = await ingest_series(db_session, FakeTMDbProvider(series_payload=FAKE_SERIES), "1396") + await db_session.commit() + + detail = await tv_service.get_series_detail(db_session, aurora_id("series", str(series_id))) + + assert "US" in detail.watch_providers + assert [p.name for p in detail.watch_providers["US"].flatrate] == ["Netflix"] diff --git a/tests/unit/test_normalizer.py b/tests/unit/test_normalizer.py index 52bd5a7..580e254 100644 --- a/tests/unit/test_normalizer.py +++ b/tests/unit/test_normalizer.py @@ -122,6 +122,35 @@ FAKE_MOVIE = { }, ] }, + "watch/providers": { + "results": { + "US": { + "link": "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=US", + "flatrate": [ + { + "provider_id": 8, + "provider_name": "Netflix", + "logo_path": "/netflix.jpg", + "display_priority": 0, + } + ], + "rent": [ + { + "provider_id": 2, + "provider_name": "Apple TV", + "logo_path": "/appletv.jpg", + "display_priority": 1, + } + ], + }, + "GB": { + "link": "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=GB", + "flatrate": [ + {"provider_id": 8, "provider_name": "Netflix", "logo_path": "/netflix.jpg"} + ], + }, + } + }, } FAKE_PERSON = { @@ -276,6 +305,32 @@ def test_normalize_movie_release_dates_skip_entries_without_a_date(): assert movie.release_dates == [] +def test_normalize_movie_maps_watch_providers_grouped_by_country_and_type(): + movie = normalize_movie(FAKE_MOVIE) + assert len(movie.watch_providers) == 3 + + us_flatrate = next( + wp for wp in movie.watch_providers if wp.country_code == "US" and wp.monetization_type == "flatrate" + ) + assert us_flatrate.provider.name == "Netflix" + assert us_flatrate.provider.external_id == "8" + assert us_flatrate.display_priority == 0 + assert us_flatrate.link == "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=US" + + us_rent = next(wp for wp in movie.watch_providers if wp.monetization_type == "rent") + assert us_rent.provider.name == "Apple TV" + + gb_flatrate = next(wp for wp in movie.watch_providers if wp.country_code == "GB") + assert gb_flatrate.link == "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=GB" + assert gb_flatrate.display_priority is None + + +def test_normalize_movie_handles_missing_watch_providers(): + minimal = {**FAKE_MOVIE, "watch/providers": {}} + movie = normalize_movie(minimal) + assert movie.watch_providers == [] + + def test_normalize_movie_handles_missing_optional_sections(): minimal = {"id": 1, "title": "Minimal Movie"} movie = normalize_movie(minimal) @@ -351,6 +406,16 @@ FAKE_SERIES = { "content_ratings": { "results": [{"iso_3166_1": "US", "rating": "TV-MA"}, {"iso_3166_1": "GB", "rating": "15"}] }, + "watch/providers": { + "results": { + "US": { + "link": "https://www.themoviedb.org/tv/1396-breaking-bad/watch?locale=US", + "flatrate": [ + {"provider_id": 1899, "provider_name": "Netflix", "logo_path": "/netflix.jpg"} + ], + } + } + }, "seasons": [ { "season_number": 1, @@ -483,6 +548,14 @@ def test_normalize_series_content_ratings_skipped_without_first_air_date(): assert series.release_dates == [] +def test_normalize_series_maps_watch_providers(): + series = normalize_series(FAKE_SERIES) + assert len(series.watch_providers) == 1 + assert series.watch_providers[0].provider.name == "Netflix" + assert series.watch_providers[0].country_code == "US" + assert series.watch_providers[0].monetization_type == "flatrate" + + def test_normalize_season_maps_episodes(): season = normalize_season(FAKE_SEASON) assert season.season_number == 1