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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -61,6 +61,7 @@ class EntityType(enum.StrEnum):
|
||||
PERSON = "person"
|
||||
COMPANY = "company"
|
||||
COLLECTION = "collection"
|
||||
WATCH_PROVIDER = "watch_provider"
|
||||
|
||||
|
||||
class IngestionJobStatus(enum.StrEnum):
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
+29
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user