Add watch providers (where to stream/rent/buy)
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s

New TMDb integration end to end: append_to_response "watch/providers",
normalizer parsing grouped by country/monetization type, a WatchProvider
catalog entry (external-id keyed, same get_or_create pattern as Company/
Collection/Person) + polymorphic WatchProviderOffering table (new
migration 0002), ingestion write (including provider logos via the
existing Artwork table), and API exposure -- embedded on movie/series
detail plus a dedicated /watch-providers sub-route, shaped to mirror
TMDb's own response for easy porting.

Note: this data comes from JustWatch via TMDb and carries its own
attribution requirement separate from TMDb's general one -- flagged in
docs/api.md, must be satisfied before showing this in the frontend.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
NixReaper
2026-09-06 04:59:02 -05:00
co-authored by Claude Sonnet 5
parent 74ebad379a
commit 6c95e18aad
21 changed files with 767 additions and 2 deletions
+95
View File
@@ -21,6 +21,7 @@ from app.models.external_id import ExternalID
from app.models.movie import ReleaseDate
from app.models.provider import Provider
from app.models.video import Video
from app.models.watch_provider import WatchProvider, WatchProviderOffering
from app.repositories import reference_repository as refs
_VALID_ARTWORK_TYPES = {t.value for t in ArtworkType}
@@ -39,6 +40,34 @@ class _NormalizedReleaseDateLike(Protocol):
note: str | None
class _NormalizedWatchProviderRefLike(Protocol):
"""Structural type for
app.services.metadata.normalizer.NormalizedWatchProviderRef."""
external_id: str
name: str
logo_path: str | None
class _NormalizedWatchProviderOfferingLike(Protocol):
"""Structural type for
app.services.metadata.normalizer.NormalizedWatchProviderOffering. The
nested `provider` is a `@property` rather than a plain annotation --
mypy otherwise treats a Protocol attribute as read-write and requires
invariant (exact) matching, which a genuinely different concrete class
can never satisfy even when structurally identical; a property makes
this member covariant, matching how it's actually used here (read
only, never reassigned)."""
country_code: str
monetization_type: str
display_priority: int | None
link: str | None
@property
def provider(self) -> _NormalizedWatchProviderRefLike: ...
class _NormalizedArtworkLike(Protocol):
"""Structural type for app.services.metadata.normalizer.NormalizedArtwork
— a repository shouldn't import from the services layer, so this
@@ -288,3 +317,69 @@ async def sync_release_dates(
)
)
await session.flush()
async def get_watch_providers(
session: AsyncSession, entity_type: str, entity_id: uuid.UUID
) -> list[WatchProviderOffering]:
stmt = (
select(WatchProviderOffering)
.options(selectinload(WatchProviderOffering.watch_provider))
.where(
WatchProviderOffering.entity_type == entity_type,
WatchProviderOffering.entity_id == entity_id,
)
.order_by(
WatchProviderOffering.country_code.asc(),
WatchProviderOffering.monetization_type.asc(),
WatchProviderOffering.display_priority.asc().nulls_last(),
)
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def sync_watch_providers(
session: AsyncSession,
*,
entity_type: str,
entity_id: uuid.UUID,
tmdb_provider_id: uuid.UUID,
offerings: Sequence[_NormalizedWatchProviderOfferingLike],
) -> list[tuple[WatchProvider, str | None]]:
"""Replace an entity's watch-provider offerings wholesale, same
delete-then-recreate as release_dates/credits/videos. Returns each
distinct WatchProvider touched paired with its current logo_path so the
caller can sync its logo artwork via company_logo_artwork + sync_artwork
-- this repository layer doesn't import the services layer to build a
NormalizedArtwork itself (see _NormalizedArtworkLike above)."""
await session.execute(
delete(WatchProviderOffering).where(
WatchProviderOffering.entity_type == entity_type,
WatchProviderOffering.entity_id == entity_id,
)
)
touched: dict[uuid.UUID, tuple[WatchProvider, str | None]] = {}
for offering in offerings:
await refs.get_or_create_country(session, offering.country_code, offering.country_code)
watch_provider = await refs.get_or_create_watch_provider(
session,
provider_id=tmdb_provider_id,
external_id=offering.provider.external_id,
name=offering.provider.name,
)
touched[watch_provider.id] = (watch_provider, offering.provider.logo_path)
session.add(
WatchProviderOffering(
entity_type=entity_type,
entity_id=entity_id,
country_code=offering.country_code,
monetization_type=offering.monetization_type,
watch_provider_id=watch_provider.id,
display_priority=offering.display_priority,
link=offering.link,
)
)
await session.flush()
return list(touched.values())
+47
View File
@@ -24,6 +24,7 @@ from app.models.genre import Genre
from app.models.keyword import Keyword
from app.models.language_country import Certification, Country, Language
from app.models.person import Person
from app.models.watch_provider import WatchProvider
from app.repositories.concurrency import create_or_recover as _create_or_recover
@@ -183,6 +184,52 @@ async def get_or_create_company(
return await _create_or_recover(session, _create, _lookup)
async def find_watch_provider_by_external_id(
session: AsyncSession, provider_id: uuid.UUID, external_id: str
) -> WatchProvider | None:
stmt = (
select(WatchProvider)
.join(
ExternalID,
(ExternalID.entity_id == WatchProvider.id) & (ExternalID.entity_type == "watch_provider"),
)
.where(ExternalID.provider_id == provider_id, ExternalID.external_id == external_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_or_create_watch_provider(
session: AsyncSession, *, provider_id: uuid.UUID, external_id: str, name: str
) -> WatchProvider:
async def _lookup() -> WatchProvider | None:
return await find_watch_provider_by_external_id(session, provider_id, external_id)
existing = await _lookup()
if existing is not None:
if existing.name != name:
existing.name = name
await session.flush()
return existing
async def _create() -> WatchProvider:
watch_provider = WatchProvider(name=name)
session.add(watch_provider)
await session.flush()
session.add(
ExternalID(
entity_type="watch_provider",
entity_id=watch_provider.id,
provider_id=provider_id,
external_id=external_id,
)
)
await session.flush()
return watch_provider
return await _create_or_recover(session, _create, _lookup)
async def find_collection_by_external_id(
session: AsyncSession, provider_id: uuid.UUID, external_id: str
) -> Collection | None: