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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user