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,
|
MovieListOut,
|
||||||
MovieRecommendationsOut,
|
MovieRecommendationsOut,
|
||||||
MovieVideosOut,
|
MovieVideosOut,
|
||||||
|
MovieWatchProvidersOut,
|
||||||
)
|
)
|
||||||
from app.services import movie_service
|
from app.services import movie_service
|
||||||
from app.services.cache import build_key
|
from app.services.cache import build_key
|
||||||
@@ -129,3 +130,13 @@ async def get_movie_recommendations(
|
|||||||
return await movie_service.get_movie_recommendations(session, aurora_id)
|
return await movie_service.get_movie_recommendations(session, aurora_id)
|
||||||
except movie_service.MovieNotFoundError as exc:
|
except movie_service.MovieNotFoundError as exc:
|
||||||
raise not_found("movie", aurora_id) from exc
|
raise not_found("movie", aurora_id) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{aurora_id}/watch-providers", response_model=MovieWatchProvidersOut)
|
||||||
|
async def get_movie_watch_providers(
|
||||||
|
aurora_id: str, session: AsyncSession = Depends(get_db)
|
||||||
|
) -> MovieWatchProvidersOut:
|
||||||
|
try:
|
||||||
|
return await movie_service.get_movie_watch_providers(session, aurora_id)
|
||||||
|
except movie_service.MovieNotFoundError as exc:
|
||||||
|
raise not_found("movie", aurora_id) from exc
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.schemas.tv import (
|
|||||||
SeriesListOut,
|
SeriesListOut,
|
||||||
SeriesSeasonsOut,
|
SeriesSeasonsOut,
|
||||||
SeriesVideosOut,
|
SeriesVideosOut,
|
||||||
|
SeriesWatchProvidersOut,
|
||||||
)
|
)
|
||||||
from app.services import tv_service
|
from app.services import tv_service
|
||||||
from app.services.cache import build_key
|
from app.services.cache import build_key
|
||||||
@@ -109,3 +110,13 @@ async def get_series_videos(
|
|||||||
return await tv_service.get_series_videos(session, aurora_id)
|
return await tv_service.get_series_videos(session, aurora_id)
|
||||||
except tv_service.SeriesNotFoundError as exc:
|
except tv_service.SeriesNotFoundError as exc:
|
||||||
raise not_found("series", aurora_id) from exc
|
raise not_found("series", aurora_id) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{aurora_id}/watch-providers", response_model=SeriesWatchProvidersOut)
|
||||||
|
async def get_series_watch_providers(
|
||||||
|
aurora_id: str, session: AsyncSession = Depends(get_db)
|
||||||
|
) -> SeriesWatchProvidersOut:
|
||||||
|
try:
|
||||||
|
return await tv_service.get_series_watch_providers(session, aurora_id)
|
||||||
|
except tv_service.SeriesNotFoundError as exc:
|
||||||
|
raise not_found("series", aurora_id) from exc
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from app.models.tv import (
|
|||||||
SeriesSpokenLanguage,
|
SeriesSpokenLanguage,
|
||||||
)
|
)
|
||||||
from app.models.video import Video
|
from app.models.video import Video
|
||||||
|
from app.models.watch_provider import WatchProvider, WatchProviderOffering
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@@ -72,4 +73,6 @@ __all__ = [
|
|||||||
"Season",
|
"Season",
|
||||||
"Episode",
|
"Episode",
|
||||||
"Video",
|
"Video",
|
||||||
|
"WatchProvider",
|
||||||
|
"WatchProviderOffering",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class EntityType(enum.StrEnum):
|
|||||||
PERSON = "person"
|
PERSON = "person"
|
||||||
COMPANY = "company"
|
COMPANY = "company"
|
||||||
COLLECTION = "collection"
|
COLLECTION = "collection"
|
||||||
|
WATCH_PROVIDER = "watch_provider"
|
||||||
|
|
||||||
|
|
||||||
class IngestionJobStatus(enum.StrEnum):
|
class IngestionJobStatus(enum.StrEnum):
|
||||||
|
|||||||
@@ -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,
|
ProviderRateLimitedError,
|
||||||
)
|
)
|
||||||
|
|
||||||
_MOVIE_APPEND = "credits,images,videos,keywords,alternative_titles,external_ids,release_dates,recommendations"
|
_MOVIE_APPEND = (
|
||||||
_SERIES_APPEND = "credits,images,videos,keywords,alternative_titles,external_ids,content_ratings"
|
"credits,images,videos,keywords,alternative_titles,external_ids,release_dates,"
|
||||||
|
"recommendations,watch/providers"
|
||||||
|
)
|
||||||
|
_SERIES_APPEND = (
|
||||||
|
"credits,images,videos,keywords,alternative_titles,external_ids,content_ratings,"
|
||||||
|
"watch/providers"
|
||||||
|
)
|
||||||
_PERSON_APPEND = "external_ids,images,combined_credits"
|
_PERSON_APPEND = "external_ids,images,combined_credits"
|
||||||
|
|
||||||
_DEFAULT_EXPONENTIAL_WAIT = wait_exponential(multiplier=0.5, max=5)
|
_DEFAULT_EXPONENTIAL_WAIT = wait_exponential(multiplier=0.5, max=5)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.models.external_id import ExternalID
|
|||||||
from app.models.movie import ReleaseDate
|
from app.models.movie import ReleaseDate
|
||||||
from app.models.provider import Provider
|
from app.models.provider import Provider
|
||||||
from app.models.video import Video
|
from app.models.video import Video
|
||||||
|
from app.models.watch_provider import WatchProvider, WatchProviderOffering
|
||||||
from app.repositories import reference_repository as refs
|
from app.repositories import reference_repository as refs
|
||||||
|
|
||||||
_VALID_ARTWORK_TYPES = {t.value for t in ArtworkType}
|
_VALID_ARTWORK_TYPES = {t.value for t in ArtworkType}
|
||||||
@@ -39,6 +40,34 @@ class _NormalizedReleaseDateLike(Protocol):
|
|||||||
note: str | None
|
note: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class _NormalizedWatchProviderRefLike(Protocol):
|
||||||
|
"""Structural type for
|
||||||
|
app.services.metadata.normalizer.NormalizedWatchProviderRef."""
|
||||||
|
|
||||||
|
external_id: str
|
||||||
|
name: str
|
||||||
|
logo_path: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class _NormalizedWatchProviderOfferingLike(Protocol):
|
||||||
|
"""Structural type for
|
||||||
|
app.services.metadata.normalizer.NormalizedWatchProviderOffering. The
|
||||||
|
nested `provider` is a `@property` rather than a plain annotation --
|
||||||
|
mypy otherwise treats a Protocol attribute as read-write and requires
|
||||||
|
invariant (exact) matching, which a genuinely different concrete class
|
||||||
|
can never satisfy even when structurally identical; a property makes
|
||||||
|
this member covariant, matching how it's actually used here (read
|
||||||
|
only, never reassigned)."""
|
||||||
|
|
||||||
|
country_code: str
|
||||||
|
monetization_type: str
|
||||||
|
display_priority: int | None
|
||||||
|
link: str | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provider(self) -> _NormalizedWatchProviderRefLike: ...
|
||||||
|
|
||||||
|
|
||||||
class _NormalizedArtworkLike(Protocol):
|
class _NormalizedArtworkLike(Protocol):
|
||||||
"""Structural type for app.services.metadata.normalizer.NormalizedArtwork
|
"""Structural type for app.services.metadata.normalizer.NormalizedArtwork
|
||||||
— a repository shouldn't import from the services layer, so this
|
— a repository shouldn't import from the services layer, so this
|
||||||
@@ -288,3 +317,69 @@ async def sync_release_dates(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_watch_providers(
|
||||||
|
session: AsyncSession, entity_type: str, entity_id: uuid.UUID
|
||||||
|
) -> list[WatchProviderOffering]:
|
||||||
|
stmt = (
|
||||||
|
select(WatchProviderOffering)
|
||||||
|
.options(selectinload(WatchProviderOffering.watch_provider))
|
||||||
|
.where(
|
||||||
|
WatchProviderOffering.entity_type == entity_type,
|
||||||
|
WatchProviderOffering.entity_id == entity_id,
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
WatchProviderOffering.country_code.asc(),
|
||||||
|
WatchProviderOffering.monetization_type.asc(),
|
||||||
|
WatchProviderOffering.display_priority.asc().nulls_last(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_watch_providers(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: uuid.UUID,
|
||||||
|
tmdb_provider_id: uuid.UUID,
|
||||||
|
offerings: Sequence[_NormalizedWatchProviderOfferingLike],
|
||||||
|
) -> list[tuple[WatchProvider, str | None]]:
|
||||||
|
"""Replace an entity's watch-provider offerings wholesale, same
|
||||||
|
delete-then-recreate as release_dates/credits/videos. Returns each
|
||||||
|
distinct WatchProvider touched paired with its current logo_path so the
|
||||||
|
caller can sync its logo artwork via company_logo_artwork + sync_artwork
|
||||||
|
-- this repository layer doesn't import the services layer to build a
|
||||||
|
NormalizedArtwork itself (see _NormalizedArtworkLike above)."""
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
delete(WatchProviderOffering).where(
|
||||||
|
WatchProviderOffering.entity_type == entity_type,
|
||||||
|
WatchProviderOffering.entity_id == entity_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
touched: dict[uuid.UUID, tuple[WatchProvider, str | None]] = {}
|
||||||
|
for offering in offerings:
|
||||||
|
await refs.get_or_create_country(session, offering.country_code, offering.country_code)
|
||||||
|
watch_provider = await refs.get_or_create_watch_provider(
|
||||||
|
session,
|
||||||
|
provider_id=tmdb_provider_id,
|
||||||
|
external_id=offering.provider.external_id,
|
||||||
|
name=offering.provider.name,
|
||||||
|
)
|
||||||
|
touched[watch_provider.id] = (watch_provider, offering.provider.logo_path)
|
||||||
|
session.add(
|
||||||
|
WatchProviderOffering(
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
country_code=offering.country_code,
|
||||||
|
monetization_type=offering.monetization_type,
|
||||||
|
watch_provider_id=watch_provider.id,
|
||||||
|
display_priority=offering.display_priority,
|
||||||
|
link=offering.link,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
return list(touched.values())
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from app.models.genre import Genre
|
|||||||
from app.models.keyword import Keyword
|
from app.models.keyword import Keyword
|
||||||
from app.models.language_country import Certification, Country, Language
|
from app.models.language_country import Certification, Country, Language
|
||||||
from app.models.person import Person
|
from app.models.person import Person
|
||||||
|
from app.models.watch_provider import WatchProvider
|
||||||
from app.repositories.concurrency import create_or_recover as _create_or_recover
|
from app.repositories.concurrency import create_or_recover as _create_or_recover
|
||||||
|
|
||||||
|
|
||||||
@@ -183,6 +184,52 @@ async def get_or_create_company(
|
|||||||
return await _create_or_recover(session, _create, _lookup)
|
return await _create_or_recover(session, _create, _lookup)
|
||||||
|
|
||||||
|
|
||||||
|
async def find_watch_provider_by_external_id(
|
||||||
|
session: AsyncSession, provider_id: uuid.UUID, external_id: str
|
||||||
|
) -> WatchProvider | None:
|
||||||
|
stmt = (
|
||||||
|
select(WatchProvider)
|
||||||
|
.join(
|
||||||
|
ExternalID,
|
||||||
|
(ExternalID.entity_id == WatchProvider.id) & (ExternalID.entity_type == "watch_provider"),
|
||||||
|
)
|
||||||
|
.where(ExternalID.provider_id == provider_id, ExternalID.external_id == external_id)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create_watch_provider(
|
||||||
|
session: AsyncSession, *, provider_id: uuid.UUID, external_id: str, name: str
|
||||||
|
) -> WatchProvider:
|
||||||
|
async def _lookup() -> WatchProvider | None:
|
||||||
|
return await find_watch_provider_by_external_id(session, provider_id, external_id)
|
||||||
|
|
||||||
|
existing = await _lookup()
|
||||||
|
if existing is not None:
|
||||||
|
if existing.name != name:
|
||||||
|
existing.name = name
|
||||||
|
await session.flush()
|
||||||
|
return existing
|
||||||
|
|
||||||
|
async def _create() -> WatchProvider:
|
||||||
|
watch_provider = WatchProvider(name=name)
|
||||||
|
session.add(watch_provider)
|
||||||
|
await session.flush()
|
||||||
|
session.add(
|
||||||
|
ExternalID(
|
||||||
|
entity_type="watch_provider",
|
||||||
|
entity_id=watch_provider.id,
|
||||||
|
provider_id=provider_id,
|
||||||
|
external_id=external_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
return watch_provider
|
||||||
|
|
||||||
|
return await _create_or_recover(session, _create, _lookup)
|
||||||
|
|
||||||
|
|
||||||
async def find_collection_by_external_id(
|
async def find_collection_by_external_id(
|
||||||
session: AsyncSession, provider_id: uuid.UUID, external_id: str
|
session: AsyncSession, provider_id: uuid.UUID, external_id: str
|
||||||
) -> Collection | None:
|
) -> Collection | None:
|
||||||
|
|||||||
@@ -139,6 +139,22 @@ class ReleaseDateOut(BaseModel):
|
|||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WatchProviderOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
logo_path: str | None = None
|
||||||
|
display_priority: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WatchProviderRegionOut(BaseModel):
|
||||||
|
link: str | None = None
|
||||||
|
flatrate: list[WatchProviderOut] = []
|
||||||
|
rent: list[WatchProviderOut] = []
|
||||||
|
buy: list[WatchProviderOut] = []
|
||||||
|
ads: list[WatchProviderOut] = []
|
||||||
|
free: list[WatchProviderOut] = []
|
||||||
|
|
||||||
|
|
||||||
class ErrorDetail(BaseModel):
|
class ErrorDetail(BaseModel):
|
||||||
code: str
|
code: str
|
||||||
message: str
|
message: str
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.schemas.common import (
|
|||||||
KeywordOut,
|
KeywordOut,
|
||||||
ReleaseDateOut,
|
ReleaseDateOut,
|
||||||
VideoOut,
|
VideoOut,
|
||||||
|
WatchProviderRegionOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -66,12 +67,18 @@ class MovieDetailOut(BaseModel):
|
|||||||
images: ImagesOut = ImagesOut()
|
images: ImagesOut = ImagesOut()
|
||||||
videos: list[VideoOut] = []
|
videos: list[VideoOut] = []
|
||||||
release_dates: list[ReleaseDateOut] = []
|
release_dates: list[ReleaseDateOut] = []
|
||||||
|
watch_providers: dict[str, WatchProviderRegionOut] = {}
|
||||||
external_ids: dict[str, str] = {}
|
external_ids: dict[str, str] = {}
|
||||||
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MovieWatchProvidersOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
results: dict[str, WatchProviderRegionOut] = {}
|
||||||
|
|
||||||
|
|
||||||
class MovieCreditsOut(BaseModel):
|
class MovieCreditsOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
cast: list[CastCreditOut] = []
|
cast: list[CastCreditOut] = []
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.schemas.common import (
|
|||||||
KeywordOut,
|
KeywordOut,
|
||||||
ReleaseDateOut,
|
ReleaseDateOut,
|
||||||
VideoOut,
|
VideoOut,
|
||||||
|
WatchProviderRegionOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -72,12 +73,18 @@ class SeriesDetailOut(BaseModel):
|
|||||||
images: ImagesOut = ImagesOut()
|
images: ImagesOut = ImagesOut()
|
||||||
videos: list[VideoOut] = []
|
videos: list[VideoOut] = []
|
||||||
release_dates: list[ReleaseDateOut] = []
|
release_dates: list[ReleaseDateOut] = []
|
||||||
|
watch_providers: dict[str, WatchProviderRegionOut] = {}
|
||||||
external_ids: dict[str, str] = {}
|
external_ids: dict[str, str] = {}
|
||||||
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SeriesWatchProvidersOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
results: dict[str, WatchProviderRegionOut] = {}
|
||||||
|
|
||||||
|
|
||||||
class SeriesSeasonsOut(BaseModel):
|
class SeriesSeasonsOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
seasons: list[SeasonBriefOut] = []
|
seasons: list[SeasonBriefOut] = []
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.ids import aurora_id
|
from app.core.ids import aurora_id
|
||||||
from app.models.artwork import Artwork
|
from app.models.artwork import Artwork
|
||||||
from app.models.credit import Credit
|
from app.models.credit import Credit
|
||||||
@@ -9,6 +11,8 @@ from app.models.enums import ArtworkType
|
|||||||
from app.models.movie import MovieAlternateTitle, ReleaseDate
|
from app.models.movie import MovieAlternateTitle, ReleaseDate
|
||||||
from app.models.tv import SeriesAlternateTitle
|
from app.models.tv import SeriesAlternateTitle
|
||||||
from app.models.video import Video
|
from app.models.video import Video
|
||||||
|
from app.models.watch_provider import WatchProviderOffering
|
||||||
|
from app.repositories import polymorphic
|
||||||
from app.schemas.common import (
|
from app.schemas.common import (
|
||||||
AlternateTitleOut,
|
AlternateTitleOut,
|
||||||
ArtworkOut,
|
ArtworkOut,
|
||||||
@@ -19,6 +23,8 @@ from app.schemas.common import (
|
|||||||
PersonBriefOut,
|
PersonBriefOut,
|
||||||
ReleaseDateOut,
|
ReleaseDateOut,
|
||||||
VideoOut,
|
VideoOut,
|
||||||
|
WatchProviderOut,
|
||||||
|
WatchProviderRegionOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -110,6 +116,34 @@ def pick_certification(release_dates: list[ReleaseDate], *, preferred_country: s
|
|||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
async def build_watch_providers_out(
|
||||||
|
session: AsyncSession, offerings: list[WatchProviderOffering]
|
||||||
|
) -> dict[str, WatchProviderRegionOut]:
|
||||||
|
out: dict[str, WatchProviderRegionOut] = {}
|
||||||
|
logo_cache: dict[object, str | None] = {}
|
||||||
|
for row in offerings:
|
||||||
|
region = out.get(row.country_code)
|
||||||
|
if region is None:
|
||||||
|
region = WatchProviderRegionOut(link=row.link)
|
||||||
|
out[row.country_code] = region
|
||||||
|
elif region.link is None and row.link is not None:
|
||||||
|
region.link = row.link
|
||||||
|
|
||||||
|
wp = row.watch_provider
|
||||||
|
if wp.id not in logo_cache:
|
||||||
|
logos = await polymorphic.get_artwork(session, "watch_provider", wp.id, ArtworkType.LOGO)
|
||||||
|
logo_cache[wp.id] = primary_artwork_path(logos, ArtworkType.LOGO)
|
||||||
|
|
||||||
|
entry = WatchProviderOut(
|
||||||
|
id=aurora_id("watch_provider", str(wp.id)),
|
||||||
|
name=wp.name,
|
||||||
|
logo_path=logo_cache[wp.id],
|
||||||
|
display_priority=row.display_priority,
|
||||||
|
)
|
||||||
|
getattr(region, row.monetization_type).append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def split_credits(
|
def split_credits(
|
||||||
credits_: list[Credit], profile_paths: dict | None = None
|
credits_: list[Credit], profile_paths: dict | None = None
|
||||||
) -> tuple[list[CastCreditOut], list[CrewCreditOut]]:
|
) -> tuple[list[CastCreditOut], list[CrewCreditOut]]:
|
||||||
|
|||||||
@@ -271,6 +271,23 @@ async def ingest_movie(
|
|||||||
await polymorphic.sync_release_dates(
|
await polymorphic.sync_release_dates(
|
||||||
session, entity_type="movie", entity_id=movie.id, release_dates=normalized.release_dates
|
session, entity_type="movie", entity_id=movie.id, release_dates=normalized.release_dates
|
||||||
)
|
)
|
||||||
|
touched_watch_providers = await polymorphic.sync_watch_providers(
|
||||||
|
session,
|
||||||
|
entity_type="movie",
|
||||||
|
entity_id=movie.id,
|
||||||
|
tmdb_provider_id=tmdb_provider.id,
|
||||||
|
offerings=normalized.watch_providers,
|
||||||
|
)
|
||||||
|
for watch_provider, logo_path in touched_watch_providers:
|
||||||
|
logo = company_logo_artwork(logo_path)
|
||||||
|
if logo is not None:
|
||||||
|
await polymorphic.sync_artwork(
|
||||||
|
session,
|
||||||
|
entity_type="watch_provider",
|
||||||
|
entity_id=watch_provider.id,
|
||||||
|
provider_id=tmdb_provider.id,
|
||||||
|
artwork_list=[logo],
|
||||||
|
)
|
||||||
await _link_external_ids(session, movie.id, tmdb_provider.id, normalized)
|
await _link_external_ids(session, movie.id, tmdb_provider.id, normalized)
|
||||||
|
|
||||||
await session.execute(
|
await session.execute(
|
||||||
|
|||||||
@@ -266,6 +266,23 @@ async def ingest_series(
|
|||||||
await polymorphic.sync_release_dates(
|
await polymorphic.sync_release_dates(
|
||||||
session, entity_type="series", entity_id=series.id, release_dates=normalized.release_dates
|
session, entity_type="series", entity_id=series.id, release_dates=normalized.release_dates
|
||||||
)
|
)
|
||||||
|
touched_watch_providers = await polymorphic.sync_watch_providers(
|
||||||
|
session,
|
||||||
|
entity_type="series",
|
||||||
|
entity_id=series.id,
|
||||||
|
tmdb_provider_id=tmdb_provider.id,
|
||||||
|
offerings=normalized.watch_providers,
|
||||||
|
)
|
||||||
|
for watch_provider, logo_path in touched_watch_providers:
|
||||||
|
logo = company_logo_artwork(logo_path)
|
||||||
|
if logo is not None:
|
||||||
|
await polymorphic.sync_artwork(
|
||||||
|
session,
|
||||||
|
entity_type="watch_provider",
|
||||||
|
entity_id=watch_provider.id,
|
||||||
|
provider_id=tmdb_provider.id,
|
||||||
|
artwork_list=[logo],
|
||||||
|
)
|
||||||
|
|
||||||
for stub in normalized.seasons:
|
for stub in normalized.seasons:
|
||||||
season = await tv_repository.get_or_create_season(session, series.id, stub.season_number)
|
season = await tv_repository.get_or_create_season(session, series.id, stub.season_number)
|
||||||
|
|||||||
@@ -202,6 +202,52 @@ def _series_release_dates(raw: dict, first_air_date: date | None) -> list[Normal
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_MONETIZATION_TYPES = ("flatrate", "rent", "buy", "ads", "free")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NormalizedWatchProviderRef:
|
||||||
|
external_id: str
|
||||||
|
name: str
|
||||||
|
logo_path: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NormalizedWatchProviderOffering:
|
||||||
|
country_code: str
|
||||||
|
monetization_type: str # one of _MONETIZATION_TYPES
|
||||||
|
provider: NormalizedWatchProviderRef
|
||||||
|
display_priority: int | None = None
|
||||||
|
link: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _watch_providers(raw: dict) -> list[NormalizedWatchProviderOffering]:
|
||||||
|
# TMDb nests this appended resource under the literal key
|
||||||
|
# "watch/providers" (slash included), grouped by country then by
|
||||||
|
# monetization type -- this data comes from JustWatch via TMDb and
|
||||||
|
# carries its own separate attribution requirement, see docs/api.md.
|
||||||
|
out: list[NormalizedWatchProviderOffering] = []
|
||||||
|
results = (raw.get("watch/providers") or {}).get("results") or {}
|
||||||
|
for country_code, country_block in results.items():
|
||||||
|
link = country_block.get("link")
|
||||||
|
for monetization_type in _MONETIZATION_TYPES:
|
||||||
|
for entry in country_block.get(monetization_type, []):
|
||||||
|
out.append(
|
||||||
|
NormalizedWatchProviderOffering(
|
||||||
|
country_code=country_code,
|
||||||
|
monetization_type=monetization_type,
|
||||||
|
provider=NormalizedWatchProviderRef(
|
||||||
|
external_id=str(entry["provider_id"]),
|
||||||
|
name=entry["provider_name"],
|
||||||
|
logo_path=entry.get("logo_path"),
|
||||||
|
),
|
||||||
|
display_priority=entry.get("display_priority"),
|
||||||
|
link=link,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class NormalizedVideo:
|
class NormalizedVideo:
|
||||||
video_type: str
|
video_type: str
|
||||||
@@ -243,6 +289,7 @@ class NormalizedMovie:
|
|||||||
collection: tuple[str, str] | None = None # (external_id, name)
|
collection: tuple[str, str] | None = None # (external_id, name)
|
||||||
recommendation_external_ids: list[str] = field(default_factory=list)
|
recommendation_external_ids: list[str] = field(default_factory=list)
|
||||||
release_dates: list[NormalizedReleaseDate] = field(default_factory=list)
|
release_dates: list[NormalizedReleaseDate] = field(default_factory=list)
|
||||||
|
watch_providers: list[NormalizedWatchProviderOffering] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -446,6 +493,7 @@ def normalize_movie(raw: dict) -> NormalizedMovie:
|
|||||||
collection=(str(collection["id"]), collection["name"]) if collection else None,
|
collection=(str(collection["id"]), collection["name"]) if collection else None,
|
||||||
recommendation_external_ids=[str(m["id"]) for m in recommendations],
|
recommendation_external_ids=[str(m["id"]) for m in recommendations],
|
||||||
release_dates=_movie_release_dates(raw),
|
release_dates=_movie_release_dates(raw),
|
||||||
|
watch_providers=_watch_providers(raw),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -595,6 +643,7 @@ class NormalizedSeries:
|
|||||||
external_ids: dict[str, str] = field(default_factory=dict)
|
external_ids: dict[str, str] = field(default_factory=dict)
|
||||||
seasons: list[NormalizedSeasonStub] = field(default_factory=list)
|
seasons: list[NormalizedSeasonStub] = field(default_factory=list)
|
||||||
release_dates: list[NormalizedReleaseDate] = field(default_factory=list)
|
release_dates: list[NormalizedReleaseDate] = field(default_factory=list)
|
||||||
|
watch_providers: list[NormalizedWatchProviderOffering] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
def normalize_series(raw: dict) -> NormalizedSeries:
|
def normalize_series(raw: dict) -> NormalizedSeries:
|
||||||
@@ -776,6 +825,7 @@ def normalize_series(raw: dict) -> NormalizedSeries:
|
|||||||
external_ids=external_ids,
|
external_ids=external_ids,
|
||||||
seasons=seasons,
|
seasons=seasons,
|
||||||
release_dates=_series_release_dates(raw, first_air_date),
|
release_dates=_series_release_dates(raw, first_air_date),
|
||||||
|
watch_providers=_watch_providers(raw),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ from app.schemas.movie import (
|
|||||||
MovieRecommendationsOut,
|
MovieRecommendationsOut,
|
||||||
MovieSummaryOut,
|
MovieSummaryOut,
|
||||||
MovieVideosOut,
|
MovieVideosOut,
|
||||||
|
MovieWatchProvidersOut,
|
||||||
)
|
)
|
||||||
from app.services.common import (
|
from app.services.common import (
|
||||||
|
build_watch_providers_out,
|
||||||
pick_certification,
|
pick_certification,
|
||||||
primary_artwork_path,
|
primary_artwork_path,
|
||||||
split_credits,
|
split_credits,
|
||||||
@@ -60,6 +62,7 @@ async def get_movie_detail(session: AsyncSession, movie_aurora_id: str) -> Movie
|
|||||||
artwork = await polymorphic.get_artwork(session, "movie", movie_id)
|
artwork = await polymorphic.get_artwork(session, "movie", movie_id)
|
||||||
videos = await polymorphic.get_videos(session, "movie", movie_id)
|
videos = await polymorphic.get_videos(session, "movie", movie_id)
|
||||||
release_dates = await polymorphic.get_release_dates(session, "movie", movie_id)
|
release_dates = await polymorphic.get_release_dates(session, "movie", movie_id)
|
||||||
|
watch_provider_rows = await polymorphic.get_watch_providers(session, "movie", movie_id)
|
||||||
external_ids = await polymorphic.get_external_ids(session, "movie", movie_id)
|
external_ids = await polymorphic.get_external_ids(session, "movie", movie_id)
|
||||||
|
|
||||||
cast, crew = split_credits(credits_)
|
cast, crew = split_credits(credits_)
|
||||||
@@ -114,6 +117,7 @@ async def get_movie_detail(session: AsyncSession, movie_aurora_id: str) -> Movie
|
|||||||
images=to_images_out(artwork),
|
images=to_images_out(artwork),
|
||||||
videos=[to_video_out(v) for v in videos],
|
videos=[to_video_out(v) for v in videos],
|
||||||
release_dates=[to_release_date_out(rd) for rd in release_dates],
|
release_dates=[to_release_date_out(rd) for rd in release_dates],
|
||||||
|
watch_providers=await build_watch_providers_out(session, watch_provider_rows),
|
||||||
external_ids=external_ids,
|
external_ids=external_ids,
|
||||||
created_at=movie.created_at,
|
created_at=movie.created_at,
|
||||||
updated_at=movie.updated_at,
|
updated_at=movie.updated_at,
|
||||||
@@ -148,6 +152,19 @@ async def get_movie_videos(session: AsyncSession, movie_aurora_id: str) -> Movie
|
|||||||
return MovieVideosOut(id=aurora_id("movie", str(movie.id)), videos=[to_video_out(v) for v in videos])
|
return MovieVideosOut(id=aurora_id("movie", str(movie.id)), videos=[to_video_out(v) for v in videos])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_movie_watch_providers(
|
||||||
|
session: AsyncSession, movie_aurora_id: str
|
||||||
|
) -> MovieWatchProvidersOut:
|
||||||
|
movie_id = await _resolve_movie_id(movie_aurora_id)
|
||||||
|
movie = await movie_repository.get_movie_by_id(session, movie_id)
|
||||||
|
if movie is None:
|
||||||
|
raise MovieNotFoundError(movie_aurora_id)
|
||||||
|
rows = await polymorphic.get_watch_providers(session, "movie", movie_id)
|
||||||
|
return MovieWatchProvidersOut(
|
||||||
|
id=aurora_id("movie", str(movie.id)), results=await build_watch_providers_out(session, rows)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_movie_recommendations(
|
async def get_movie_recommendations(
|
||||||
session: AsyncSession, movie_aurora_id: str
|
session: AsyncSession, movie_aurora_id: str
|
||||||
) -> MovieRecommendationsOut:
|
) -> MovieRecommendationsOut:
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ from app.schemas.tv import (
|
|||||||
SeriesSeasonsOut,
|
SeriesSeasonsOut,
|
||||||
SeriesSummaryOut,
|
SeriesSummaryOut,
|
||||||
SeriesVideosOut,
|
SeriesVideosOut,
|
||||||
|
SeriesWatchProvidersOut,
|
||||||
)
|
)
|
||||||
from app.services.common import (
|
from app.services.common import (
|
||||||
|
build_watch_providers_out,
|
||||||
pick_certification,
|
pick_certification,
|
||||||
primary_artwork_path,
|
primary_artwork_path,
|
||||||
split_credits,
|
split_credits,
|
||||||
@@ -109,6 +111,7 @@ async def get_series_detail(session: AsyncSession, series_aurora_id: str) -> Ser
|
|||||||
artwork = await polymorphic.get_artwork(session, "series", series_id)
|
artwork = await polymorphic.get_artwork(session, "series", series_id)
|
||||||
videos = await polymorphic.get_videos(session, "series", series_id)
|
videos = await polymorphic.get_videos(session, "series", series_id)
|
||||||
release_dates = await polymorphic.get_release_dates(session, "series", series_id)
|
release_dates = await polymorphic.get_release_dates(session, "series", series_id)
|
||||||
|
watch_provider_rows = await polymorphic.get_watch_providers(session, "series", series_id)
|
||||||
external_ids = await polymorphic.get_external_ids(session, "series", series_id)
|
external_ids = await polymorphic.get_external_ids(session, "series", series_id)
|
||||||
|
|
||||||
cast, crew, creators = _split_series_credits(credits_)
|
cast, crew, creators = _split_series_credits(credits_)
|
||||||
@@ -145,6 +148,7 @@ async def get_series_detail(session: AsyncSession, series_aurora_id: str) -> Ser
|
|||||||
images=to_images_out(artwork),
|
images=to_images_out(artwork),
|
||||||
videos=[to_video_out(v) for v in videos],
|
videos=[to_video_out(v) for v in videos],
|
||||||
release_dates=[to_release_date_out(rd) for rd in release_dates],
|
release_dates=[to_release_date_out(rd) for rd in release_dates],
|
||||||
|
watch_providers=await build_watch_providers_out(session, watch_provider_rows),
|
||||||
external_ids=external_ids,
|
external_ids=external_ids,
|
||||||
created_at=series.created_at,
|
created_at=series.created_at,
|
||||||
updated_at=series.updated_at,
|
updated_at=series.updated_at,
|
||||||
@@ -193,6 +197,19 @@ async def get_series_videos(session: AsyncSession, series_aurora_id: str) -> Ser
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_series_watch_providers(
|
||||||
|
session: AsyncSession, series_aurora_id: str
|
||||||
|
) -> SeriesWatchProvidersOut:
|
||||||
|
series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError)
|
||||||
|
series = await tv_repository.get_series_by_id(session, series_id)
|
||||||
|
if series is None:
|
||||||
|
raise SeriesNotFoundError(series_aurora_id)
|
||||||
|
rows = await polymorphic.get_watch_providers(session, "series", series_id)
|
||||||
|
return SeriesWatchProvidersOut(
|
||||||
|
id=aurora_id("series", str(series.id)), results=await build_watch_providers_out(session, rows)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_season_detail(session: AsyncSession, season_aurora_id: str) -> SeasonDetailOut:
|
async def get_season_detail(session: AsyncSession, season_aurora_id: str) -> SeasonDetailOut:
|
||||||
season_id = _resolve_id(season_aurora_id, "season", SeasonNotFoundError)
|
season_id = _resolve_id(season_aurora_id, "season", SeasonNotFoundError)
|
||||||
season = await tv_repository.get_season_by_id(session, season_id)
|
season = await tv_repository.get_season_by_id(session, season_id)
|
||||||
|
|||||||
+29
@@ -49,6 +49,7 @@ GET /api/v1/search?q=the%20matrix
|
|||||||
| GET | `/api/v1/movies/{aurora_id}/images` |
|
| GET | `/api/v1/movies/{aurora_id}/images` |
|
||||||
| GET | `/api/v1/movies/{aurora_id}/videos` |
|
| GET | `/api/v1/movies/{aurora_id}/videos` |
|
||||||
| GET | `/api/v1/movies/{aurora_id}/recommendations` |
|
| GET | `/api/v1/movies/{aurora_id}/recommendations` |
|
||||||
|
| GET | `/api/v1/movies/{aurora_id}/watch-providers` |
|
||||||
| GET | `/api/v1/movies/now-playing` |
|
| GET | `/api/v1/movies/now-playing` |
|
||||||
| GET | `/api/v1/movies/upcoming` |
|
| GET | `/api/v1/movies/upcoming` |
|
||||||
|
|
||||||
@@ -131,6 +132,33 @@ ingested:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`watch_providers` (embedded on movie/series detail as shown above, or its own
|
||||||
|
`GET .../watch-providers`) is grouped by country then by monetization type —
|
||||||
|
mirroring TMDb's own `watch/providers` response shape directly, since that's
|
||||||
|
the shape a client porting from TMDb already expects:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "aur_movie_01m1q7p1c...",
|
||||||
|
"results": {
|
||||||
|
"US": {
|
||||||
|
"link": "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=US",
|
||||||
|
"flatrate": [{ "id": "aur_watch_provider_...", "name": "Netflix", "logo_path": "https://...", "display_priority": 0 }],
|
||||||
|
"rent": [],
|
||||||
|
"buy": [],
|
||||||
|
"ads": [],
|
||||||
|
"free": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**This data comes from JustWatch via TMDb, and carries its own attribution
|
||||||
|
requirement separate from TMDb's general one** — TMDb's terms require
|
||||||
|
sourcing watch-provider data back to JustWatch specifically if you display
|
||||||
|
it, not just crediting TMDb itself. Read TMDb's current terms for the
|
||||||
|
exact required wording/logo before showing this in the frontend.
|
||||||
|
|
||||||
`GET /api/v1/movies/now-playing` and `GET /api/v1/movies/upcoming` use the
|
`GET /api/v1/movies/now-playing` and `GET /api/v1/movies/upcoming` use the
|
||||||
`release_dates` data above rather than `release_date`/`year`: `region`
|
`release_dates` data above rather than `release_date`/`year`: `region`
|
||||||
(ISO 3166-1 alpha-2, default `US`), `page`, `page_size` — same
|
(ISO 3166-1 alpha-2, default `US`), `page`, `page_size` — same
|
||||||
@@ -152,6 +180,7 @@ now-playing, soonest for upcoming) rather than appearing twice.
|
|||||||
| GET | `/api/v1/series/{aurora_id}/credits` |
|
| GET | `/api/v1/series/{aurora_id}/credits` |
|
||||||
| GET | `/api/v1/series/{aurora_id}/images` |
|
| GET | `/api/v1/series/{aurora_id}/images` |
|
||||||
| GET | `/api/v1/series/{aurora_id}/videos` |
|
| GET | `/api/v1/series/{aurora_id}/videos` |
|
||||||
|
| GET | `/api/v1/series/{aurora_id}/watch-providers` |
|
||||||
| GET | `/api/v1/seasons/{aurora_id}` |
|
| GET | `/api/v1/seasons/{aurora_id}` |
|
||||||
| GET | `/api/v1/episodes/{aurora_id}` |
|
| GET | `/api/v1/episodes/{aurora_id}` |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""watch providers
|
||||||
|
|
||||||
|
Revision ID: 0002_watch_providers
|
||||||
|
Revises: 0001_initial_schema
|
||||||
|
Create Date: 2026-09-06
|
||||||
|
|
||||||
|
Adds the where-to-watch tables (section 17, additive): a WatchProvider
|
||||||
|
catalog entry (Netflix, Apple TV, etc. -- TMDb's own provider_id links in
|
||||||
|
via the existing external_ids table, same pattern as Company/Collection/
|
||||||
|
Person) and WatchProviderOffering, a polymorphic (entity_type, entity_id)
|
||||||
|
table like release_dates, one row per (movie/series, country,
|
||||||
|
monetization type, provider).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0002_watch_providers"
|
||||||
|
down_revision: str | None = "0001_initial_schema"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"watch_providers",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"watch_provider_offerings",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("entity_type", sa.String(30), nullable=False),
|
||||||
|
sa.Column("entity_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("country_code", sa.String(2), sa.ForeignKey("countries.code"), nullable=False),
|
||||||
|
sa.Column("monetization_type", sa.String(20), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"watch_provider_id",
|
||||||
|
sa.Uuid(),
|
||||||
|
sa.ForeignKey("watch_providers.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("display_priority", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("link", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_watch_provider_offerings_entity",
|
||||||
|
"watch_provider_offerings",
|
||||||
|
["entity_type", "entity_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_watch_provider_offerings_entity", table_name="watch_provider_offerings")
|
||||||
|
op.drop_table("watch_provider_offerings")
|
||||||
|
op.drop_table("watch_providers")
|
||||||
@@ -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 = {
|
FAKE_PERSON = {
|
||||||
@@ -276,6 +305,32 @@ def test_normalize_movie_release_dates_skip_entries_without_a_date():
|
|||||||
assert movie.release_dates == []
|
assert movie.release_dates == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_movie_maps_watch_providers_grouped_by_country_and_type():
|
||||||
|
movie = normalize_movie(FAKE_MOVIE)
|
||||||
|
assert len(movie.watch_providers) == 3
|
||||||
|
|
||||||
|
us_flatrate = next(
|
||||||
|
wp for wp in movie.watch_providers if wp.country_code == "US" and wp.monetization_type == "flatrate"
|
||||||
|
)
|
||||||
|
assert us_flatrate.provider.name == "Netflix"
|
||||||
|
assert us_flatrate.provider.external_id == "8"
|
||||||
|
assert us_flatrate.display_priority == 0
|
||||||
|
assert us_flatrate.link == "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=US"
|
||||||
|
|
||||||
|
us_rent = next(wp for wp in movie.watch_providers if wp.monetization_type == "rent")
|
||||||
|
assert us_rent.provider.name == "Apple TV"
|
||||||
|
|
||||||
|
gb_flatrate = next(wp for wp in movie.watch_providers if wp.country_code == "GB")
|
||||||
|
assert gb_flatrate.link == "https://www.themoviedb.org/movie/603-the-matrix/watch?locale=GB"
|
||||||
|
assert gb_flatrate.display_priority is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_movie_handles_missing_watch_providers():
|
||||||
|
minimal = {**FAKE_MOVIE, "watch/providers": {}}
|
||||||
|
movie = normalize_movie(minimal)
|
||||||
|
assert movie.watch_providers == []
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_movie_handles_missing_optional_sections():
|
def test_normalize_movie_handles_missing_optional_sections():
|
||||||
minimal = {"id": 1, "title": "Minimal Movie"}
|
minimal = {"id": 1, "title": "Minimal Movie"}
|
||||||
movie = normalize_movie(minimal)
|
movie = normalize_movie(minimal)
|
||||||
@@ -351,6 +406,16 @@ FAKE_SERIES = {
|
|||||||
"content_ratings": {
|
"content_ratings": {
|
||||||
"results": [{"iso_3166_1": "US", "rating": "TV-MA"}, {"iso_3166_1": "GB", "rating": "15"}]
|
"results": [{"iso_3166_1": "US", "rating": "TV-MA"}, {"iso_3166_1": "GB", "rating": "15"}]
|
||||||
},
|
},
|
||||||
|
"watch/providers": {
|
||||||
|
"results": {
|
||||||
|
"US": {
|
||||||
|
"link": "https://www.themoviedb.org/tv/1396-breaking-bad/watch?locale=US",
|
||||||
|
"flatrate": [
|
||||||
|
{"provider_id": 1899, "provider_name": "Netflix", "logo_path": "/netflix.jpg"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"seasons": [
|
"seasons": [
|
||||||
{
|
{
|
||||||
"season_number": 1,
|
"season_number": 1,
|
||||||
@@ -483,6 +548,14 @@ def test_normalize_series_content_ratings_skipped_without_first_air_date():
|
|||||||
assert series.release_dates == []
|
assert series.release_dates == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_series_maps_watch_providers():
|
||||||
|
series = normalize_series(FAKE_SERIES)
|
||||||
|
assert len(series.watch_providers) == 1
|
||||||
|
assert series.watch_providers[0].provider.name == "Netflix"
|
||||||
|
assert series.watch_providers[0].country_code == "US"
|
||||||
|
assert series.watch_providers[0].monetization_type == "flatrate"
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_season_maps_episodes():
|
def test_normalize_season_maps_episodes():
|
||||||
season = normalize_season(FAKE_SEASON)
|
season = normalize_season(FAKE_SEASON)
|
||||||
assert season.season_number == 1
|
assert season.season_number == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user