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
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:
co-authored by
Claude Sonnet 5
parent
74ebad379a
commit
6c95e18aad
@@ -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]]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user