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,
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
+11
View File
@@ -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
+3
View File
@@ -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",
]
+1
View File
@@ -61,6 +61,7 @@ class EntityType(enum.StrEnum):
PERSON = "person"
COMPANY = "company"
COLLECTION = "collection"
WATCH_PROVIDER = "watch_provider"
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,
)
_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)
+95
View File
@@ -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())
+47
View File
@@ -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:
+16
View File
@@ -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
+7
View File
@@ -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] = []
+7
View File
@@ -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] = []
+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)
+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}/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}` |
@@ -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 = {
@@ -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