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
+11
View File
@@ -19,6 +19,7 @@ from app.schemas.movie import (
MovieListOut, MovieListOut,
MovieRecommendationsOut, MovieRecommendationsOut,
MovieVideosOut, MovieVideosOut,
MovieWatchProvidersOut,
) )
from app.services import movie_service from app.services import movie_service
from app.services.cache import build_key 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) return await movie_service.get_movie_recommendations(session, aurora_id)
except movie_service.MovieNotFoundError as exc: except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from 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
+11
View File
@@ -19,6 +19,7 @@ from app.schemas.tv import (
SeriesListOut, SeriesListOut,
SeriesSeasonsOut, SeriesSeasonsOut,
SeriesVideosOut, SeriesVideosOut,
SeriesWatchProvidersOut,
) )
from app.services import tv_service from app.services import tv_service
from app.services.cache import build_key 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) return await tv_service.get_series_videos(session, aurora_id)
except tv_service.SeriesNotFoundError as exc: except tv_service.SeriesNotFoundError as exc:
raise not_found("series", aurora_id) from 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
+3
View File
@@ -33,6 +33,7 @@ from app.models.tv import (
SeriesSpokenLanguage, SeriesSpokenLanguage,
) )
from app.models.video import Video from app.models.video import Video
from app.models.watch_provider import WatchProvider, WatchProviderOffering
__all__ = [ __all__ = [
"Base", "Base",
@@ -72,4 +73,6 @@ __all__ = [
"Season", "Season",
"Episode", "Episode",
"Video", "Video",
"WatchProvider",
"WatchProviderOffering",
] ]
+1
View File
@@ -61,6 +61,7 @@ class EntityType(enum.StrEnum):
PERSON = "person" PERSON = "person"
COMPANY = "company" COMPANY = "company"
COLLECTION = "collection" COLLECTION = "collection"
WATCH_PROVIDER = "watch_provider"
class IngestionJobStatus(enum.StrEnum): class IngestionJobStatus(enum.StrEnum):
+58
View File
@@ -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"]
+8 -2
View File
@@ -27,8 +27,14 @@ from app.providers.base import (
ProviderRateLimitedError, ProviderRateLimitedError,
) )
_MOVIE_APPEND = "credits,images,videos,keywords,alternative_titles,external_ids,release_dates,recommendations" _MOVIE_APPEND = (
_SERIES_APPEND = "credits,images,videos,keywords,alternative_titles,external_ids,content_ratings" "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" _PERSON_APPEND = "external_ids,images,combined_credits"
_DEFAULT_EXPONENTIAL_WAIT = wait_exponential(multiplier=0.5, max=5) _DEFAULT_EXPONENTIAL_WAIT = wait_exponential(multiplier=0.5, max=5)
+95
View File
@@ -21,6 +21,7 @@ from app.models.external_id import ExternalID
from app.models.movie import ReleaseDate from app.models.movie import ReleaseDate
from app.models.provider import Provider from app.models.provider import Provider
from app.models.video import Video from app.models.video import Video
from app.models.watch_provider import WatchProvider, WatchProviderOffering
from app.repositories import reference_repository as refs from app.repositories import reference_repository as refs
_VALID_ARTWORK_TYPES = {t.value for t in ArtworkType} _VALID_ARTWORK_TYPES = {t.value for t in ArtworkType}
@@ -39,6 +40,34 @@ class _NormalizedReleaseDateLike(Protocol):
note: str | None 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): class _NormalizedArtworkLike(Protocol):
"""Structural type for app.services.metadata.normalizer.NormalizedArtwork """Structural type for app.services.metadata.normalizer.NormalizedArtwork
— a repository shouldn't import from the services layer, so this — a repository shouldn't import from the services layer, so this
@@ -288,3 +317,69 @@ async def sync_release_dates(
) )
) )
await session.flush() 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())
+47
View File
@@ -24,6 +24,7 @@ from app.models.genre import Genre
from app.models.keyword import Keyword from app.models.keyword import Keyword
from app.models.language_country import Certification, Country, Language from app.models.language_country import Certification, Country, Language
from app.models.person import Person 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 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) 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( async def find_collection_by_external_id(
session: AsyncSession, provider_id: uuid.UUID, external_id: str session: AsyncSession, provider_id: uuid.UUID, external_id: str
) -> Collection | None: ) -> Collection | None:
+16
View File
@@ -139,6 +139,22 @@ class ReleaseDateOut(BaseModel):
note: str | None = None 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): class ErrorDetail(BaseModel):
code: str code: str
message: str message: str
+7
View File
@@ -17,6 +17,7 @@ from app.schemas.common import (
KeywordOut, KeywordOut,
ReleaseDateOut, ReleaseDateOut,
VideoOut, VideoOut,
WatchProviderRegionOut,
) )
@@ -66,12 +67,18 @@ class MovieDetailOut(BaseModel):
images: ImagesOut = ImagesOut() images: ImagesOut = ImagesOut()
videos: list[VideoOut] = [] videos: list[VideoOut] = []
release_dates: list[ReleaseDateOut] = [] release_dates: list[ReleaseDateOut] = []
watch_providers: dict[str, WatchProviderRegionOut] = {}
external_ids: dict[str, str] = {} external_ids: dict[str, str] = {}
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
class MovieWatchProvidersOut(BaseModel):
id: str
results: dict[str, WatchProviderRegionOut] = {}
class MovieCreditsOut(BaseModel): class MovieCreditsOut(BaseModel):
id: str id: str
cast: list[CastCreditOut] = [] cast: list[CastCreditOut] = []
+7
View File
@@ -16,6 +16,7 @@ from app.schemas.common import (
KeywordOut, KeywordOut,
ReleaseDateOut, ReleaseDateOut,
VideoOut, VideoOut,
WatchProviderRegionOut,
) )
@@ -72,12 +73,18 @@ class SeriesDetailOut(BaseModel):
images: ImagesOut = ImagesOut() images: ImagesOut = ImagesOut()
videos: list[VideoOut] = [] videos: list[VideoOut] = []
release_dates: list[ReleaseDateOut] = [] release_dates: list[ReleaseDateOut] = []
watch_providers: dict[str, WatchProviderRegionOut] = {}
external_ids: dict[str, str] = {} external_ids: dict[str, str] = {}
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
class SeriesWatchProvidersOut(BaseModel):
id: str
results: dict[str, WatchProviderRegionOut] = {}
class SeriesSeasonsOut(BaseModel): class SeriesSeasonsOut(BaseModel):
id: str id: str
seasons: list[SeasonBriefOut] = [] seasons: list[SeasonBriefOut] = []
+34
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ids import aurora_id from app.core.ids import aurora_id
from app.models.artwork import Artwork from app.models.artwork import Artwork
from app.models.credit import Credit 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.movie import MovieAlternateTitle, ReleaseDate
from app.models.tv import SeriesAlternateTitle from app.models.tv import SeriesAlternateTitle
from app.models.video import Video from app.models.video import Video
from app.models.watch_provider import WatchProviderOffering
from app.repositories import polymorphic
from app.schemas.common import ( from app.schemas.common import (
AlternateTitleOut, AlternateTitleOut,
ArtworkOut, ArtworkOut,
@@ -19,6 +23,8 @@ from app.schemas.common import (
PersonBriefOut, PersonBriefOut,
ReleaseDateOut, ReleaseDateOut,
VideoOut, VideoOut,
WatchProviderOut,
WatchProviderRegionOut,
) )
@@ -110,6 +116,34 @@ def pick_certification(release_dates: list[ReleaseDate], *, preferred_country: s
return fallback 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( def split_credits(
credits_: list[Credit], profile_paths: dict | None = None credits_: list[Credit], profile_paths: dict | None = None
) -> tuple[list[CastCreditOut], list[CrewCreditOut]]: ) -> tuple[list[CastCreditOut], list[CrewCreditOut]]:
+17
View File
@@ -271,6 +271,23 @@ async def ingest_movie(
await polymorphic.sync_release_dates( await polymorphic.sync_release_dates(
session, entity_type="movie", entity_id=movie.id, release_dates=normalized.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 _link_external_ids(session, movie.id, tmdb_provider.id, normalized)
await session.execute( await session.execute(
@@ -266,6 +266,23 @@ async def ingest_series(
await polymorphic.sync_release_dates( await polymorphic.sync_release_dates(
session, entity_type="series", entity_id=series.id, release_dates=normalized.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: for stub in normalized.seasons:
season = await tv_repository.get_or_create_season(session, series.id, stub.season_number) 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 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 @dataclass
class NormalizedVideo: class NormalizedVideo:
video_type: str video_type: str
@@ -243,6 +289,7 @@ class NormalizedMovie:
collection: tuple[str, str] | None = None # (external_id, name) collection: tuple[str, str] | None = None # (external_id, name)
recommendation_external_ids: list[str] = field(default_factory=list) recommendation_external_ids: list[str] = field(default_factory=list)
release_dates: list[NormalizedReleaseDate] = field(default_factory=list) release_dates: list[NormalizedReleaseDate] = field(default_factory=list)
watch_providers: list[NormalizedWatchProviderOffering] = field(default_factory=list)
@dataclass @dataclass
@@ -446,6 +493,7 @@ def normalize_movie(raw: dict) -> NormalizedMovie:
collection=(str(collection["id"]), collection["name"]) if collection else None, collection=(str(collection["id"]), collection["name"]) if collection else None,
recommendation_external_ids=[str(m["id"]) for m in recommendations], recommendation_external_ids=[str(m["id"]) for m in recommendations],
release_dates=_movie_release_dates(raw), 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) external_ids: dict[str, str] = field(default_factory=dict)
seasons: list[NormalizedSeasonStub] = field(default_factory=list) seasons: list[NormalizedSeasonStub] = field(default_factory=list)
release_dates: list[NormalizedReleaseDate] = 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: def normalize_series(raw: dict) -> NormalizedSeries:
@@ -776,6 +825,7 @@ def normalize_series(raw: dict) -> NormalizedSeries:
external_ids=external_ids, external_ids=external_ids,
seasons=seasons, seasons=seasons,
release_dates=_series_release_dates(raw, first_air_date), 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, MovieRecommendationsOut,
MovieSummaryOut, MovieSummaryOut,
MovieVideosOut, MovieVideosOut,
MovieWatchProvidersOut,
) )
from app.services.common import ( from app.services.common import (
build_watch_providers_out,
pick_certification, pick_certification,
primary_artwork_path, primary_artwork_path,
split_credits, 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) artwork = await polymorphic.get_artwork(session, "movie", movie_id)
videos = await polymorphic.get_videos(session, "movie", movie_id) videos = await polymorphic.get_videos(session, "movie", movie_id)
release_dates = await polymorphic.get_release_dates(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) external_ids = await polymorphic.get_external_ids(session, "movie", movie_id)
cast, crew = split_credits(credits_) 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), images=to_images_out(artwork),
videos=[to_video_out(v) for v in videos], videos=[to_video_out(v) for v in videos],
release_dates=[to_release_date_out(rd) for rd in release_dates], 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, external_ids=external_ids,
created_at=movie.created_at, created_at=movie.created_at,
updated_at=movie.updated_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]) 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( async def get_movie_recommendations(
session: AsyncSession, movie_aurora_id: str session: AsyncSession, movie_aurora_id: str
) -> MovieRecommendationsOut: ) -> MovieRecommendationsOut:
+17
View File
@@ -24,8 +24,10 @@ from app.schemas.tv import (
SeriesSeasonsOut, SeriesSeasonsOut,
SeriesSummaryOut, SeriesSummaryOut,
SeriesVideosOut, SeriesVideosOut,
SeriesWatchProvidersOut,
) )
from app.services.common import ( from app.services.common import (
build_watch_providers_out,
pick_certification, pick_certification,
primary_artwork_path, primary_artwork_path,
split_credits, 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) artwork = await polymorphic.get_artwork(session, "series", series_id)
videos = await polymorphic.get_videos(session, "series", series_id) videos = await polymorphic.get_videos(session, "series", series_id)
release_dates = await polymorphic.get_release_dates(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) external_ids = await polymorphic.get_external_ids(session, "series", series_id)
cast, crew, creators = _split_series_credits(credits_) 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), images=to_images_out(artwork),
videos=[to_video_out(v) for v in videos], videos=[to_video_out(v) for v in videos],
release_dates=[to_release_date_out(rd) for rd in release_dates], 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, external_ids=external_ids,
created_at=series.created_at, created_at=series.created_at,
updated_at=series.updated_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: async def get_season_detail(session: AsyncSession, season_aurora_id: str) -> SeasonDetailOut:
season_id = _resolve_id(season_aurora_id, "season", SeasonNotFoundError) season_id = _resolve_id(season_aurora_id, "season", SeasonNotFoundError)
season = await tv_repository.get_season_by_id(session, season_id) season = await tv_repository.get_season_by_id(session, season_id)
+29
View File
@@ -49,6 +49,7 @@ GET /api/v1/search?q=the%20matrix
| GET | `/api/v1/movies/{aurora_id}/images` | | GET | `/api/v1/movies/{aurora_id}/images` |
| GET | `/api/v1/movies/{aurora_id}/videos` | | GET | `/api/v1/movies/{aurora_id}/videos` |
| GET | `/api/v1/movies/{aurora_id}/recommendations` | | 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/now-playing` |
| GET | `/api/v1/movies/upcoming` | | 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 `GET /api/v1/movies/now-playing` and `GET /api/v1/movies/upcoming` use the
`release_dates` data above rather than `release_date`/`year`: `region` `release_dates` data above rather than `release_date`/`year`: `region`
(ISO 3166-1 alpha-2, default `US`), `page`, `page_size` — same (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}/credits` |
| GET | `/api/v1/series/{aurora_id}/images` | | GET | `/api/v1/series/{aurora_id}/images` |
| GET | `/api/v1/series/{aurora_id}/videos` | | GET | `/api/v1/series/{aurora_id}/videos` |
| GET | `/api/v1/series/{aurora_id}/watch-providers` |
| GET | `/api/v1/seasons/{aurora_id}` | | GET | `/api/v1/seasons/{aurora_id}` |
| GET | `/api/v1/episodes/{aurora_id}` | | GET | `/api/v1/episodes/{aurora_id}` |
@@ -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")
+188
View File
@@ -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"]
+73
View File
@@ -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 = { FAKE_PERSON = {
@@ -276,6 +305,32 @@ def test_normalize_movie_release_dates_skip_entries_without_a_date():
assert movie.release_dates == [] 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(): def test_normalize_movie_handles_missing_optional_sections():
minimal = {"id": 1, "title": "Minimal Movie"} minimal = {"id": 1, "title": "Minimal Movie"}
movie = normalize_movie(minimal) movie = normalize_movie(minimal)
@@ -351,6 +406,16 @@ FAKE_SERIES = {
"content_ratings": { "content_ratings": {
"results": [{"iso_3166_1": "US", "rating": "TV-MA"}, {"iso_3166_1": "GB", "rating": "15"}] "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": [ "seasons": [
{ {
"season_number": 1, "season_number": 1,
@@ -483,6 +548,14 @@ def test_normalize_series_content_ratings_skipped_without_first_air_date():
assert series.release_dates == [] 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(): def test_normalize_season_maps_episodes():
season = normalize_season(FAKE_SEASON) season = normalize_season(FAKE_SEASON)
assert season.season_number == 1 assert season.season_number == 1