"""Provider abstraction (section 13). A `MetadataProvider` fetches **raw**, unnormalized data from one external source. It never writes to the database and never returns Aurora entities — that's the normalizer/matcher/ingestion pipeline's job (see `app/services/metadata/` and `app/services/ingestion/`). This keeps adapters replaceable: swapping TMDb for another provider means writing a new class here, not touching ingestion logic. """ from __future__ import annotations from abc import ABC, abstractmethod from typing import Any class ProviderError(Exception): """Raised for any provider-side failure (auth, not-found, rate limit, network). Ingestion jobs catch this and record it, they don't crash.""" class ProviderNotConfiguredError(ProviderError): """Raised when a required credential (e.g. TMDB_API_KEY) is missing.""" class ProviderNotFoundError(ProviderError): """Raised when the provider has no record for the given external ID.""" class ProviderRateLimitedError(ProviderError): """Raised when the provider responds 429. Carries how long the provider itself asked callers to wait (`Retry-After`, seconds) so a caller can back off by the right amount rather than guessing — see `app/providers/tmdb.py:TMDbProvider._get` for where this is retried.""" def __init__(self, message: str, *, retry_after: float) -> None: super().__init__(message) self.retry_after = retry_after class MetadataProvider(ABC): slug: str name: str @abstractmethod async def get_movie(self, external_id: str) -> dict[str, Any]: """Fetch raw movie data, including credits/images/videos/keywords/ alternate titles/external IDs/release dates where the provider supports fetching them in one call.""" @abstractmethod async def get_series(self, external_id: str) -> dict[str, Any]: """Fetch raw series data (without season/episode bodies).""" @abstractmethod async def get_season(self, series_external_id: str, season_number: int) -> dict[str, Any]: """Fetch raw season data, including its episodes.""" @abstractmethod async def get_person(self, external_id: str) -> dict[str, Any]: """Fetch raw person data, including external IDs and images.""" @abstractmethod async def get_collection(self, external_id: str) -> dict[str, Any]: """Fetch raw collection/franchise data: name, overview, poster/ backdrop, and its member movies (TMDb's "parts").""" @abstractmethod async def search_movie(self, query: str) -> list[dict[str, Any]]: """Search this provider's catalog for movies matching `query`. Used to seed ingestion, not exposed directly to API clients.""" async def get_trending(self, media_type: str, time_window: str) -> list[dict[str, Any]]: """Fetch a ranked list of raw items (movies or series, per `media_type`) currently trending over `time_window` ("day" | "week"). Not every provider has a trending concept, so this is concrete rather than abstract (like `aclose` below) — a provider that doesn't support it simply doesn't override this, and callers get an empty list rather than every other provider implementation having to stub out a method that means nothing for them.""" return [] async def aclose(self) -> None: """Release any held resources (HTTP clients, ...). Optional."""