From 7d08dbec713d9b6a173d8f3881f75b321c9b9bf8 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Sat, 3 Oct 2026 14:35:57 +0200 Subject: [PATCH] add template provider --- lib/streaming_providers/__init__.py | 6 +- lib/streaming_providers/base/errors.py | 262 ++++++++++++++++ .../base/managers/__init__.py | 37 +++ .../base/managers/channel.py | 118 ++++++++ lib/streaming_providers/base/managers/epg.py | 127 ++++++++ lib/streaming_providers/base/managers/vod.py | 130 ++++++++ lib/streaming_providers/base/protocols.py | 139 +++++++++ lib/streaming_providers/base/vod.py | 110 +++++++ .../providers/_template/README.md | 285 ++++++++++++++++++ .../providers/_template/__init__.py | 15 + .../providers/_template/auth.py | 220 ++++++++++++++ .../providers/_template/channel_manager.py | 55 ++++ .../providers/_template/constants.py | 62 ++++ .../providers/_template/drm_manager.py | 152 ++++++++++ .../providers/_template/epg_manager.py | 46 +++ .../providers/_template/models.py | 146 +++++++++ .../providers/_template/provider.py | 249 +++++++++++++++ .../providers/_template/vod_manager.py | 67 ++++ 18 files changed, 2224 insertions(+), 2 deletions(-) create mode 100644 lib/streaming_providers/base/errors.py create mode 100644 lib/streaming_providers/base/managers/__init__.py create mode 100644 lib/streaming_providers/base/managers/channel.py create mode 100644 lib/streaming_providers/base/managers/epg.py create mode 100644 lib/streaming_providers/base/managers/vod.py create mode 100644 lib/streaming_providers/base/protocols.py create mode 100644 lib/streaming_providers/base/vod.py create mode 100644 lib/streaming_providers/providers/_template/README.md create mode 100644 lib/streaming_providers/providers/_template/__init__.py create mode 100644 lib/streaming_providers/providers/_template/auth.py create mode 100644 lib/streaming_providers/providers/_template/channel_manager.py create mode 100644 lib/streaming_providers/providers/_template/constants.py create mode 100644 lib/streaming_providers/providers/_template/drm_manager.py create mode 100644 lib/streaming_providers/providers/_template/epg_manager.py create mode 100644 lib/streaming_providers/providers/_template/models.py create mode 100644 lib/streaming_providers/providers/_template/provider.py create mode 100644 lib/streaming_providers/providers/_template/vod_manager.py diff --git a/lib/streaming_providers/__init__.py b/lib/streaming_providers/__init__.py index 4d3d219..27f0b46 100644 --- a/lib/streaming_providers/__init__.py +++ b/lib/streaming_providers/__init__.py @@ -37,8 +37,10 @@ def _discover_providers(): for item in os.listdir(providers_dir): provider_path = os.path.join(providers_dir, item) - # Skip if not a directory or if it starts with __ - if not os.path.isdir(provider_path) or item.startswith("__"): + # Skip if not a directory. Skip private/dunder directories -- names + # starting with "_" are reserved for scaffolding (e.g. _template) + # and are never treated as real providers. + if not os.path.isdir(provider_path) or item.startswith("_"): continue # Check if __init__.py exists in the provider directory diff --git a/lib/streaming_providers/base/errors.py b/lib/streaming_providers/base/errors.py new file mode 100644 index 0000000..d4cc7c4 --- /dev/null +++ b/lib/streaming_providers/base/errors.py @@ -0,0 +1,262 @@ +# streaming_providers/base/errors.py +""" +Shared provider error hierarchy. + +Rationale +--------- +Every provider raises its own exception types for the same handful of +conditions: expired auth, geo-block, entitlement denial, content removed, +rate limits, server faults, playback restrictions, catchup-required items. +Downstream code (operations layer, Kodi plugin, UI) then has to special-case +per provider. + +This module provides one hierarchy that all providers can subclass. Providers +MAY keep their own exception class names (existing callers can still catch +those); the only requirement is that the provider's classes inherit from the +appropriate base here. + +This is a *convention*, not a mechanical enforcement. Nothing prevents a +provider from raising a bare Exception. But if a provider raises from this +hierarchy, callers get uniform handling for free. + +Usage in a provider: + + from ...base.errors import AuthError, GeoBlockError + + class MyVodAuthError(AuthError): ... + class MyVodGeoBlockError(GeoBlockError): ... + +Usage in a caller: + + from ...base.errors import AuthError, GeoBlockError, ProviderError + + try: + manifest = provider.get_manifest(content_id) + except AuthError: + ... # refresh credentials / prompt for re-login + except GeoBlockError: + ... # not available in your region + except ProviderError as e: + ... # generic fallback; e.code may carry a provider-specific code + +Error codes +----------- +Some providers (Discovery+) emit machine-readable codes alongside the +exception type. This base supports an optional `code` attribute. Providers +that don't use codes leave it None. +""" + +from __future__ import annotations + +from typing import Any, Optional, Tuple + + +class ProviderError(Exception): + """ + Base class for all provider errors. + + Every subclass accepts (message, *, status=None, url=None, code=None) and + stores them as attributes so callers can inspect without re-parsing the + message string. + + Note on pickling / copy.deepcopy: + Subclasses are allowed to have different __init__ signatures + (e.g. ChannelNotFoundError(channel_id)) and to carry extra payload + attributes. __reduce__ below reconstructs via __new__ and restores + __dict__ as state, so it works regardless of the subclass's + constructor shape and preserves every attribute. + """ + + def __init__( + self, + message: str, + *, + status: Optional[int] = None, + url: Optional[str] = None, + code: Optional[str] = None, + ) -> None: + super().__init__(message) + self.status = status + self.url = url + self.code = code + + def __repr__(self) -> str: + msg = self.args[0] if self.args else "" + parts = [self.__class__.__name__, f"({msg!r}"] + if self.status is not None: + parts.append(f", status={self.status}") + if self.code is not None: + parts.append(f", code={self.code!r}") + parts.append(")") + return "".join(parts) + + def __reduce__(self) -> Tuple[Any, ...]: + """ + Preserve ALL attributes across pickle / copy.deepcopy. + + Returns a 3-tuple (callable, args, state). The annotation is + Tuple[Any, ...] because __reduce__ can return a 2-tuple or a + 3-tuple depending on whether state is provided; the base + object.__reduce__ signature reflects this. + + Exception.__reduce__ uses args only, which drops keyword-only fields + (status, url, code) and any subclass payload. Reconstruction goes + through __new__ so the subclass's __init__ is not called -- this + means subclass constructors with different signatures (e.g. + PlaybackRestrictedException(reason, error_code)) work fine, and + __dict__ is restored as-is. + """ + return ( + _rebuild_provider_error, + (self.__class__, self.args), + self.__dict__.copy(), + ) + + +def _rebuild_provider_error( + cls: type, args: Tuple +) -> "ProviderError": + """ + Reconstruction helper for ProviderError.__reduce__. + + Deliberately does not call cls(...). Creates a bare instance and sets + args; pickle then restores __dict__ as state, so every attribute the + original had comes back regardless of the subclass constructor. + """ + exc = cls.__new__(cls) + exc.args = tuple(args) + return exc + + +# --------------------------------------------------------------------------- +# Auth / session +# --------------------------------------------------------------------------- + +class AuthError(ProviderError): + """401-style failure: token expired, invalid, or missing.""" + + +class CredentialsError(AuthError): + """Invalid username/password (as opposed to a stale token).""" + + +class SessionExpiredError(AuthError): + """Server-side session is gone; a full re-login is required.""" + + +# --------------------------------------------------------------------------- +# Access control +# --------------------------------------------------------------------------- + +class GeoBlockError(ProviderError): + """Content is not available in the caller's region.""" + + +class EntitlementError(ProviderError): + """Authenticated, but not entitled to this content.""" + + +class AccountRestrictedError(EntitlementError): + """Account-level gate (e.g. VOD disabled for the whole account).""" + + +class PlaybackRestrictedError(ProviderError): + """Playback is refused for a reason not covered above.""" + + +# --------------------------------------------------------------------------- +# Content lookup +# --------------------------------------------------------------------------- + +class NotFoundError(ProviderError): + """Content is known to the provider but has been removed. + + At the manager top level, "this manager doesn't handle that content_id" + is signalled by returning None/[] -- not by raising NotFoundError. See + the "None vs exception" section in providers/_template/README.md. + + NotFoundError is for the case where the provider *knows* the content + belongs in its domain but has been removed (e.g. a VOD detail fetch + returns 404 for an id that was recently listed). The orchestrator's + router will try other managers, then re-raise this if nobody resolves. + """ + + +class BadRequestError(ProviderError): + """400 -- the request was malformed for the endpoint called. + + Used as a routing signal by providers that guess endpoint shape from an + opaque content_id (e.g. Magenta's page-vs-component dispatch). The + orchestrator's router does NOT swallow this -- providers that need that + behavior override handles_content_id() instead. + """ + + +# --------------------------------------------------------------------------- +# Transport / server +# --------------------------------------------------------------------------- + +class RateLimitError(ProviderError): + """429 -- caller should back off and retry.""" + + +class ServerError(ProviderError): + """5xx -- retryable.""" + + +class TransportError(ProviderError): + """Connection-level failure (DNS, TLS, timeout) -- no HTTP status.""" + + +# --------------------------------------------------------------------------- +# Flow control +# --------------------------------------------------------------------------- + +class CatchupRequiredError(ProviderError): + """This 'VOD' item is actually a catch-up entry from a linear channel.""" + + +class NotImplementedYetError(ProviderError): + """Feature captured but not yet wired up.""" + + +class ConfigurationError(ProviderError): + """Provider is misconfigured.""" + + +# --------------------------------------------------------------------------- +# HTTP status -> error class heuristic +# --------------------------------------------------------------------------- + +def default_error_for_status_heuristic( + status: int, + message: str, + *, + url: Optional[str] = None, + body_snippet: str = "", +) -> ProviderError: + """ + Heuristic mapping from HTTP status to ProviderError subclass. + + This is a heuristic, not a contract. Providers with their own error + classification should construct the specific error directly rather than + relying on this. The 403 branch in particular uses a substring match on + body_snippet and is intentionally shallow so providers do not depend on + it accidentally. + """ + if status == 400: + return BadRequestError(message, status=status, url=url) + if status == 401: + return AuthError(message, status=status, url=url) + if status == 403: + lower = body_snippet.lower() + if "geo" in lower or "region" in lower: + return GeoBlockError(message, status=status, url=url) + return EntitlementError(message, status=status, url=url) + if status == 404: + return NotFoundError(message, status=status, url=url) + if status == 429: + return RateLimitError(message, status=status, url=url) + if 500 <= status < 600: + return ServerError(message, status=status, url=url) + return ProviderError(message, status=status, url=url) \ No newline at end of file diff --git a/lib/streaming_providers/base/managers/__init__.py b/lib/streaming_providers/base/managers/__init__.py new file mode 100644 index 0000000..6d9277c --- /dev/null +++ b/lib/streaming_providers/base/managers/__init__.py @@ -0,0 +1,37 @@ +# streaming_providers/base/managers/__init__.py +""" +Manager ABCs for streaming providers. + +Each manager wraps one capability area (channels, VOD, EPG) with a fixed +public interface. Providers subclass and implement the abstract methods; +the concrete methods (headers, DRM defaults, search no-ops) come for free. + +Design rules +------------ +* Constructors take four required collaborators -- http_manager, auth, + country, config -- plus keyword-only extras (caches, collaborators). +* Managers never hold a reference to the provider. Shared state is passed + in at construction. +* Managers raise from base.errors; nothing catches broadly. +* VOD navigation always returns VodPage (base/vod.py). + +On the None-vs-exception rule +----------------------------- +Manager top-level methods (get_*_manifest, get_*_drm) signal "this manager +doesn't handle that content_id" by returning None / [] -- NOT by raising +NotFoundError. See providers/_template/README.md for the full rule. + +Rigidity note +------------- +The ABCs enforce the *method names and signatures* through the abstract +method mechanism. They do NOT enforce that providers raise the right +error classes, or that they return the right content shapes. Those are +conventions documented in the template. Treat the ABCs as "the interface +is fixed" not as "everything about a manager is enforced." +""" + +from .channel import ChannelManager +from .vod import VodManager +from .epg import EpgManager + +__all__ = ["ChannelManager", "VodManager", "EpgManager"] \ No newline at end of file diff --git a/lib/streaming_providers/base/managers/channel.py b/lib/streaming_providers/base/managers/channel.py new file mode 100644 index 0000000..ba5736c --- /dev/null +++ b/lib/streaming_providers/base/managers/channel.py @@ -0,0 +1,118 @@ +# streaming_providers/base/managers/channel.py +""" +ChannelManager ABC. + +Public interface +---------------- + handles_content_id(content_id) -> bool [concrete] + get_channels(**kw) -> List[Channel] [abstract] + get_channel_manifest(content_id, **kw) -> Optional[str] [abstract] + get_channel_manifest_headers(content_id, **kw) -> Dict[str, str] [concrete] + get_segment_headers(content_id, **kw) -> Dict[str, str] [concrete] + get_channel_drm(content_id, **kw) -> List[DRMConfig] [concrete] + +Constructor contract +-------------------- +Four required keyword-only collaborators. Subclasses that need extra state +declare additional keyword-only args and store them on self AFTER calling +super().__init__. The base does NOT accept **kwargs -- a typo at a call +site becomes an immediate TypeError, which is what you want. + +Return-value conventions +------------------------ +get_channel_manifest / get_channel_drm return None / [] when this manager +does not handle content_id. Auth / geo / entitlement / rate-limit / server +failures propagate as exceptions from base.errors. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + +from ..models import Channel, DRMConfig +from ..protocols import AuthProtocol +from ..utils.logger import logger + + +class ChannelManager(ABC): + """Abstract base for provider channel managers.""" + + def __init__( + self, + *, + http_manager: Any, + auth: AuthProtocol, + country: str, + config: Any, + ) -> None: + # Sanity check the Auth collaborator against the runtime_checkable + # protocol. isinstance on a runtime_checkable Protocol only verifies + # method presence, not signatures -- that's the intended check here. + if not isinstance(auth, AuthProtocol): + logger.warning( + f"{self.__class__.__name__}: auth does not match AuthProtocol " + f"(missing one of get_access_token / build_headers / " + f"invalidate). Got {type(auth).__name__}." + ) + self.http_manager = http_manager + self.auth = auth + self.country = country + self.config = config + + # ------------------------------------------------------------------ + # Routing + # ------------------------------------------------------------------ + + def handles_content_id(self, content_id: str) -> bool: + """ + True if this manager handles the given content_id. + + Default: True. Override in providers whose channel content_ids have + a distinguishable grammar (e.g. numeric-only for live channels). + The orchestrator uses this to route get_manifest / get_drm without + a wasted request to the wrong manager. + """ + return True + + # ------------------------------------------------------------------ + # Abstract + # ------------------------------------------------------------------ + + @abstractmethod + def get_channels(self, **kw: Any) -> List[Channel]: + """Return the provider's live channels (Channel or subclass).""" + raise NotImplementedError + + @abstractmethod + def get_channel_manifest( + self, content_id: str, **kw: Any + ) -> Optional[str]: + """ + Return the manifest URL for a channel, or None if this manager + doesn't handle content_id. Do NOT raise NotFoundError for "not in + my domain" -- the router uses the None return to fall through. + """ + raise NotImplementedError + + # ------------------------------------------------------------------ + # Concrete + # ------------------------------------------------------------------ + + def get_channel_manifest_headers( + self, content_id: str, **kw: Any + ) -> Dict[str, str]: + """Headers for the manifest request. Default: auth headers.""" + return self.auth.build_headers() + + def get_segment_headers( + self, content_id: str, **kw: Any + ) -> Dict[str, str]: + """Headers for segment requests. Default: manifest headers.""" + return self.get_channel_manifest_headers(content_id, **kw) + + def get_channel_drm( + self, content_id: str, **kw: Any + ) -> List[DRMConfig]: + """DRM for a channel. Default: no DRM.""" + return [] \ No newline at end of file diff --git a/lib/streaming_providers/base/managers/epg.py b/lib/streaming_providers/base/managers/epg.py new file mode 100644 index 0000000..385f7e7 --- /dev/null +++ b/lib/streaming_providers/base/managers/epg.py @@ -0,0 +1,127 @@ +# streaming_providers/base/managers/epg.py +""" +EpgManager ABC. + +Public interface +---------------- + epg_window -> Tuple[int, int] [concrete] + implements_epg -> bool [concrete] + handles_channel_id(channel_id) -> bool [concrete] + get_epg(channel_id, start_time, end_time, **kw) + -> List[EPGEntry] [abstract] + get_epg_grid(channel_ids, start_time, end_time, **kw) + -> Dict[str, List[EPGEntry]] [concrete] + get_program_details(program_id, **kw) -> Optional[EPGProgramDetails] [concrete] + +Constructor contract +-------------------- +Four required keyword-only collaborators. No **kwargs. + +epg_window returns (past_days, future_days). (0, 0) means no EPG support. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from ..models.epg_models import EPGEntry, EPGProgramDetails +from ..protocols import AuthProtocol +from ..utils.logger import logger + + +class EpgManager(ABC): + """Abstract base for provider EPG managers.""" + + def __init__( + self, + *, + http_manager: Any, + auth: AuthProtocol, + country: str, + config: Any, + ) -> None: + if not isinstance(auth, AuthProtocol): + logger.warning( + f"{self.__class__.__name__}: auth does not match AuthProtocol " + f"(missing one of get_access_token / build_headers / " + f"invalidate). Got {type(auth).__name__}." + ) + self.http_manager = http_manager + self.auth = auth + self.country = country + self.config = config + + # ------------------------------------------------------------------ + # Capability + # ------------------------------------------------------------------ + + @property + def epg_window(self) -> Tuple[int, int]: + """(past_days, future_days). Default (0, 0) means no EPG.""" + return 0, 0 + + @property + def implements_epg(self) -> bool: + """True when this manager actually provides EPG data.""" + return self.epg_window != (0, 0) + + def handles_channel_id(self, channel_id: str) -> bool: + """ + True if this manager provides EPG for the given channel. + + Default: True. Override when only a subset of channels has EPG + (e.g. radio-only, or a whitelist). Used by the default + get_epg_grid() to skip unhandled channels without a call. + """ + return True + + # ------------------------------------------------------------------ + # Abstract + # ------------------------------------------------------------------ + + @abstractmethod + def get_epg( + self, + channel_id: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + **kw: Any, + ) -> List[EPGEntry]: + """Return EPG entries for one channel, or [] if none.""" + raise NotImplementedError + + # ------------------------------------------------------------------ + # Concrete + # ------------------------------------------------------------------ + + def get_epg_grid( + self, + channel_ids: List[str], + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + **kw: Any, + ) -> Dict[str, List[EPGEntry]]: + """ + Batch EPG for multiple channels. + + Default: loops get_epg per channel, skipping channels for which + handles_channel_id() is False (returns [] for those without calling + get_epg). Override when the provider has a native batch endpoint. + """ + result: Dict[str, List[EPGEntry]] = {} + for cid in channel_ids: + if not self.handles_channel_id(cid): + result[cid] = [] + continue + result[cid] = self.get_epg( + cid, start_time=start_time, end_time=end_time, **kw + ) + return result + + def get_program_details( + self, program_id: str, **kw: Any + ) -> Optional[EPGProgramDetails]: + """Rich metadata for one programme. Default: not supported.""" + return None \ No newline at end of file diff --git a/lib/streaming_providers/base/managers/vod.py b/lib/streaming_providers/base/managers/vod.py new file mode 100644 index 0000000..85ba3a0 --- /dev/null +++ b/lib/streaming_providers/base/managers/vod.py @@ -0,0 +1,130 @@ +# streaming_providers/base/managers/vod.py +""" +VodManager ABC. + +Public interface +---------------- + handles_content_id(content_id) -> bool [concrete] + get_vod_category(content_id="", cursor=None, page_size=24, **kw) + -> VodPage [abstract] + get_vod_manifest(content_id, **kw) -> Optional[str] [abstract] + search_vod(query, cursor=None, page_size=24, **kw) -> VodPage [concrete] + get_vod_manifest_headers(content_id, **kw) -> Dict[str, str] [concrete] + get_vod_drm(content_id, **kw) -> List[DRMConfig] [concrete] + +content_id grammar +------------------ +content_id is an opaque token. Each provider picks its own grammar and +documents it here. The base class does not parse content_id. Examples: + + RTL+ "folder_", "program_", "clip_", ... + HRTi "catalogue_", "series_--", ... + Discovery "/sports", "/sports/alpine-skiing", ... + MoveTV ["root", "Film", "123"] encoded into a string + +Constructor contract +-------------------- +Four required keyword-only collaborators. No **kwargs. Subclasses accept +extra keyword-only args explicitly and call super().__init__ with only +the four required. A typo at a call site becomes an immediate TypeError. + +Return-value conventions +------------------------ +get_vod_manifest / get_vod_drm return None / [] when this manager does not +handle content_id. Auth / geo / entitlement / rate-limit / server failures +propagate as exceptions from base.errors. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + +from ..models import DRMConfig +from ..protocols import AuthProtocol +from ..utils.logger import logger +from ..vod import VodPage + + +class VodManager(ABC): + """Abstract base for provider VOD managers.""" + + def __init__( + self, + *, + http_manager: Any, + auth: AuthProtocol, + country: str, + config: Any, + ) -> None: + if not isinstance(auth, AuthProtocol): + logger.warning( + f"{self.__class__.__name__}: auth does not match AuthProtocol " + f"(missing one of get_access_token / build_headers / " + f"invalidate). Got {type(auth).__name__}." + ) + self.http_manager = http_manager + self.auth = auth + self.country = country + self.config = config + + # ------------------------------------------------------------------ + # Routing + # ------------------------------------------------------------------ + + def handles_content_id(self, content_id: str) -> bool: + """ + True if this manager handles the given content_id. + + Default: True. Override in providers whose VOD content_ids have a + distinguishable grammar (e.g. start with "details_" or "clip_"). + """ + return True + + # ------------------------------------------------------------------ + # Abstract + # ------------------------------------------------------------------ + + @abstractmethod + def get_vod_category( + self, + content_id: str = "", + cursor: Optional[str] = None, + page_size: int = 24, + **kw: Any, + ) -> VodPage: + """Return children of a VOD node. Empty content_id is the root.""" + raise NotImplementedError + + @abstractmethod + def get_vod_manifest( + self, content_id: str, **kw: Any + ) -> Optional[str]: + """Return the manifest URL, or None if not handled by this manager.""" + raise NotImplementedError + + # ------------------------------------------------------------------ + # Concrete + # ------------------------------------------------------------------ + + def search_vod( + self, + query: str, + cursor: Optional[str] = None, + page_size: int = 24, + **kw: Any, + ) -> VodPage: + """Search the catalogue. Default: no results. Override if supported.""" + return VodPage() + + def get_vod_manifest_headers( + self, content_id: str, **kw: Any + ) -> Dict[str, str]: + """Headers for the manifest request. Default: auth headers.""" + return self.auth.build_headers() + + def get_vod_drm( + self, content_id: str, **kw: Any + ) -> List[DRMConfig]: + """DRM for a VOD item. Default: no DRM.""" + return [] \ No newline at end of file diff --git a/lib/streaming_providers/base/protocols.py b/lib/streaming_providers/base/protocols.py new file mode 100644 index 0000000..27cddbe --- /dev/null +++ b/lib/streaming_providers/base/protocols.py @@ -0,0 +1,139 @@ +# streaming_providers/base/protocols.py +""" +Structural typing protocols for provider collaborators. + +These are NOT base classes. Providers match them by shape, not by +inheritance. Using them as type annotations gives IDE / mypy checking +without imposing a runtime hierarchy. + +Why protocols and not ABCs +-------------------------- +The Auth contract varies across providers -- RTL+ has three tokens, +Discovery has cookie-based session state, HRTi authorizes per-content +sessions. Forcing a single abstract base would require escape hatches. +A Protocol captures the *minimum shared shape* without forbidding +provider-specific extensions. + +The manager ABCs (base/managers/*) are different: their interfaces are +actually shared, so they are ABCs. Only Auth, DRM, and the playback- +authorization step use protocols. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, runtime_checkable + +if TYPE_CHECKING: + from .models import DRMConfig + + +@runtime_checkable +class AuthProtocol(Protocol): + """ + Minimal shared Auth interface. + + Every provider's Auth class must provide these three methods. Additional + methods (get_scoped_token, get_session_context, authorize_playback, or + provider-specific helpers) are allowed but not required. + + runtime_checkable means isinstance(obj, AuthProtocol) works at runtime, + checking only for method presence -- not signatures. + """ + + def get_access_token(self, force_refresh: bool = False) -> str: + """Return the raw token string (no scheme prefix).""" + ... + + def build_headers( + self, token: Optional[str] = None, **opts: Any + ) -> Dict[str, str]: + """ + Return request-ready headers including auth. + + If token is None, use the cached token (via get_access_token). + """ + ... + + def invalidate(self) -> None: + """Drop cached token and session state. Called after a 401.""" + ... + + +@runtime_checkable +class PlaybackAuthorizationProtocol(Protocol): + """ + Optional protocol for the provider-specific pre-playback step. + + Not every provider has this. Providers that do match this shape by + convention when they want to expose it as a stable entry point. + """ + + def authorize_playback( + self, content_id: str, **opts: Any + ) -> Dict[str, Any]: + """Perform the provider's pre-playback authorization step.""" + ... + + +@runtime_checkable +class DrmManagerProtocol(Protocol): + """ + Shape for a provider's dedicated DRM manager (if it has one). + + This is a protocol, not an ABC. Providers implement it however fits + their DRM source: as a dedicated DrmManager class, or folded into the + channel/vod managers. The protocol exists so callers and tests have a + name for the shape, and so type checkers can verify it if a provider + chooses to annotate. + + Providers with no DRM do not implement this at all -- their + provider.get_drm() returns [] and implements_drm is False. + + Two architectures are supported (see providers/_template/README.md, + section "DRM", for when to pick which): + + * Dedicated manager: implement get_drm_configs() on a class + matching this protocol, and wire it in the + provider's _build_drm(). + * Folded into managers: override get_channel_drm() on the + ChannelManager and/or get_vod_drm() on the + VodManager instead. The provider's + implements_drm flag is derived from whether + either override is present. + + New providers should prefer the dedicated-manager shape unless the + DRM call shares significant state with the manifest step -- see the + README for the tradeoff. + + Four existing patterns to model after (see + providers/_template/drm_manager.py for file-level references): + + RTL+ per-content upfront token via lib_drmtoday. + Magenta licence URL constructed from token claims + /user/account. + Discovery DRM arrives with the playbackInfo response. + HRTi session id becomes a base64 auth blob via lib_drmtoday. + """ + + def get_drm_configs( + self, + content_id: str, + content_type: Optional[str] = None, + **opts: Any, + ) -> List["DRMConfig"]: + """ + Return the DRM configuration(s) for the given content. + + content_type is an optional hint ("live" | "vod" | "event" | + "catchup"). When None, the manager infers the type from its own + content_id grammar -- which is the preferred mode, since the + manager knows its own grammar better than the caller does. + + Callers that already know the type (e.g. the backend's streaming + route, which has already resolved the item) should pass it + explicitly so the manager skips the inference step. + + Return [] when this provider has no DRM for the content. Auth / + geo / entitlement / rate-limit / server failures propagate as + exceptions from base.errors. + """ + ... \ No newline at end of file diff --git a/lib/streaming_providers/base/vod.py b/lib/streaming_providers/base/vod.py new file mode 100644 index 0000000..0566d63 --- /dev/null +++ b/lib/streaming_providers/base/vod.py @@ -0,0 +1,110 @@ +# streaming_providers/base/vod.py +""" +Shared VOD return type. + +Prior to this module, providers returned VOD category children in three +different shapes (dict-with-entries, bare list, ...). This module defines +one canonical shape. + +Pagination rule: `next_cursor is None` is the authoritative end-of-list +signal (exposed as `has_more`). `total` may be missing; do not use it to +decide whether to keep paging. + +Truthiness: bool(page) is driven by __len__, so an empty page is falsy and +a page with entries is truthy -- matching list semantics. An empty page +with a next_cursor is falsy; check `page.has_more` when the caller means +"are there more pages?". +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Iterator, List, Optional, Union + +from .models.vod import VodCategory, VodItem + + +VodEntry = Union[VodCategory, VodItem] + + +@dataclass +class VodPage: + """ + One page of VOD results. + + Attributes: + entries: Mixed list of VodCategory and VodItem. + next_cursor: Opaque continuation token; None = no next page. + total: Optional total count; None = unknown. + + Truthiness follows list semantics via __len__: an empty page is falsy. + For pagination, use `has_more` -- NOT bool(page) -- because a page can + legitimately have zero entries and a non-None cursor. + """ + + entries: List[VodEntry] = field(default_factory=list) + next_cursor: Optional[str] = None + total: Optional[int] = None + + @property + def has_more(self) -> bool: + """True when the provider indicated a next page exists. + + This is the correct pagination check. `bool(page)` answers + "are there entries to display?" -- a different question. + """ + return self.next_cursor is not None + + def __len__(self) -> int: + # Drives both len(page) and bool(page) via Python's default + # truthiness rule for objects with __len__. Matches list semantics. + return len(self.entries) + + def __iter__(self) -> Iterator[VodEntry]: + return iter(self.entries) + + def to_dict(self) -> dict: + return { + "entries": [ + e.to_dict() if hasattr(e, "to_dict") else e + for e in self.entries + ], + "next_cursor": self.next_cursor, + "total": self.total, + } + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def empty_page() -> VodPage: + """Return a fresh empty VodPage with no pagination.""" + return VodPage() + + +def normalize_vod_result(result) -> VodPage: + """ + Coerce a legacy return value into a VodPage. + + Migration bridge. Once every provider returns VodPage directly, this + becomes redundant. + """ + if isinstance(result, VodPage): + return result + if result is None: + return VodPage() + if isinstance(result, dict): + return VodPage( + entries=result.get("entries") or [], + next_cursor=result.get("next_cursor"), + total=result.get("total"), + ) + if isinstance(result, list): + return VodPage(entries=result) + import logging + logging.getLogger(__name__).warning( + f"normalize_vod_result: unexpected type {type(result).__name__}; " + f"returning empty page" + ) + return VodPage() \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/README.md b/lib/streaming_providers/providers/_template/README.md new file mode 100644 index 0000000..a508c56 --- /dev/null +++ b/lib/streaming_providers/providers/_template/README.md @@ -0,0 +1,285 @@ +# Provider template + +Copy this directory to `providers/{your_provider}/`, rename the classes, +and fill in the stubs. Read this file first — it explains the contract. + +## What you get for free + +- HTTP manager setup, proxying, retries. +- Credential storage / Kodi sync via the base `settings_manager`. +- Token caching and session persistence (in your Auth class). +- Capability flags (`implements_vod`, `implements_epg`) — derived from + whether you wire up the corresponding manager. +- Shared error types, shared `VodPage` shape, shared `Channel` base. + +## What you implement + +1. **Auth** — `auth.py`. Writes the three shared methods and any optional + extensions the provider needs. +2. **Managers** — `channel_manager.py`, `vod_manager.py`, `epg_manager.py`. + Each subclasses the corresponding ABC from `base/managers/` and + implements the abstract methods. +3. **Provider wiring** — `provider.py`. Fills in `_build_*` factory + methods; returns `None` for capabilities the provider doesn't have. +4. **Constants** — `constants.py`. URLs, endpoints, static headers. +5. **Models** (optional) — `models.py`. Only if you need a custom Channel + or AuthToken subclass. + +## The manager ABCs + +Subclass `base.managers.ChannelManager`, `VodManager`, `EpgManager`. Each +has the same constructor contract and a small public interface. + +**Constructor contract:** + + def __init__( + self, + *, + http_manager, + auth, + country, + config, + your_extra_cache=None, # any extra keyword-only args you need + ): + super().__init__( + http_manager=http_manager, + auth=auth, + country=country, + config=config, + ) + self._your_extra_cache = your_extra_cache or {} + +The base constructor accepts ONLY the four required collaborators. There +is no `**provider_opts` passthrough. This is deliberate: a typo at a call +site (`channel_cache=` instead of `channels_cache=`) raises `TypeError` +immediately rather than silently producing a half-configured manager. + +Subclasses declare their extra keyword-only args explicitly, call +`super().__init__` with only the four required, and store extra state on +`self` after the super call. + +Managers never hold a reference to the provider. If you need something +the provider owns, inject it at construction. + +## Return-value rule: None vs exception + +This is the single most important convention. Every manager follows it. + +**Return None / [] for "not in my domain."** + +`get_channel_manifest("clip_123")` from a channel manager that only +handles live channels returns `None`. Not an error — the router uses it +to fall through to the VOD manager. Same for `get_vod_manifest`, +`get_channel_drm`, `get_vod_drm`, `get_epg`. + +**Raise for genuine failures.** + +- `AuthError` — token expired or invalid. Caller refreshes and retries. +- `GeoBlockError` — content not available in the caller's region. +- `EntitlementError` — authenticated but not subscribed. +- `RateLimitError` — caller should back off. +- `ServerError`, `TransportError` — transient. +- `PlaybackRestrictedError` — refused for a non-entitlement reason. +- `NotFoundError` — the provider knows this content belongs in its domain + but has been removed. +- `BadRequestError` — 400 from the wrong endpoint. Not swallowed by the + router; providers that use 400 as a routing signal should override + `handles_content_id()` instead. + +`NotFoundError` and `None` mean different things, and the router preserves +the distinction: + + provider.get_manifest("gone_123") -> raises NotFoundError + provider.get_manifest("unknown_456") -> returns None + +The router tracks any `NotFoundError` raised by a manager, tries the next +manager, and re-raises the last `NotFoundError` if nobody resolved. So a +caller can distinguish "removed" from "not in anyone's domain." + +Rule of thumb: **"I don't handle this" is a return value; "this is a real +failure" is an exception.** + +## Routing with handles_content_id() + +The orchestrator's `get_manifest` / `get_drm` try managers in order. To +avoid a wasted request, override `handles_content_id()` on managers whose +content_ids have a distinguishable shape: + + # In your VodManager: + def handles_content_id(self, content_id: str) -> bool: + return content_id.startswith(("details_", "clip_", "program_")) + + # In your ChannelManager: + def handles_content_id(self, content_id: str) -> bool: + return content_id.isdigit() + +If your provider has no content_id grammar, leave it returning `True` and +the router falls back to try-and-catch. + +`BadRequestError` is intentionally not caught by the router. Providers +that use a 400 response as an endpoint-dispatch signal (like Magenta's +page-vs-component guess) should override `handles_content_id()` instead, +so the router never has to guess. + +## The Auth protocol + +Auth is a documented protocol (see `base/protocols.py`), not an ABC. +Every provider writes: + + get_access_token(force_refresh=False) -> str + Raw token string. No scheme prefix. + + build_headers(token=None, **opts) -> Dict[str, str] + Request-ready headers including auth. If token is None, use the + cached token (via get_access_token). + + invalidate() -> None + Drop cached token and session state. Called after 401s. + +Optional extensions — implement only if needed: + + get_scoped_token(scope, **opts) -> Optional[str] + Secondary tokens. RTL+ uses this for bedrock / upfront. + + get_session_context() -> Optional[Dict[str, Any]] + Opaque session state needed by build_headers. Magenta uses this + for guest device/session ids; Discovery for cookie + session-state + headers. + + authorize_playback(content_id, **opts) -> Dict[str, Any] + Provider-specific pre-playback step. HRTi's AuthorizeSession, + MoveTV's live-source fetch, Discovery's playbackInfo POST, + RTL+'s upfront token, Magenta's persona JWT retrieval. + +There is no fixed interface for `authorize_playback`. The name is a +convention; the shape is provider-specific. + +The manager ABCs verify `auth` against `AuthProtocol` at construction +time via `isinstance` (this works because the protocol is +`@runtime_checkable`). The check confirms method *presence*, not +signatures — a mismatched signature will not be caught here. + +## Conventions + +- **Custom Channel / AuthToken subclasses are fine.** Call + `super().to_dict()` in your override. MoveTV, Discovery, and HRTi all + do this. +- **Provider owns caches; managers borrow them.** Create caches in the + provider's `__init__`; pass them into manager constructors. +- **Content ID grammar is provider-specific.** Pick one and document it + in the manager's docstring. +- **Playback authorization is provider-specific.** Don't force it into a + shared interface. See the five existing providers for five shapes. + +## DRM + +DRM is optional. Providers with no DRM leave `_build_drm()` returning +`None` and don't override `get_channel_drm` / `get_vod_drm`. The +provider's `get_drm()` returns `[]` and `implements_drm` is `False`. + +Providers with DRM pick ONE of two architectures: + +**Architecture 1 — dedicated DRM manager (preferred for new providers)** + +Create `drm_manager.py` with a class matching +`base.protocols.DrmManagerProtocol`. Wire it in the provider's +`_build_drm()` factory. The provider's `get_drm()` delegates to it. + +When to pick this: DRM is a distinct step with its own data sources +(upfront tokens, licence URL construction, session authorization) that +does not share significant state with the manifest fetch. + +See `drm_manager.py` in this directory for four concrete reference +patterns (RTL+ upfront token, Magenta constructed URL, Discovery +playbackInfo, HRTi session id). Pick the closest and adapt. + +**Architecture 2 — folded into channel/vod managers** + +Override `get_channel_drm()` on your `ChannelManager` and/or +`get_vod_drm()` on your `VodManager`. Leave `_build_drm()` returning +`None`. The provider's `get_drm()` routes through the manager list, and +`implements_drm` is derived from whether either override is present. + +When to pick this: the DRM call shares state with the manifest fetch +(session ids, playbackInfo responses) and a separate manager would have +to be handed that state anyway. HRTi and Magenta use this shape. + +**The three method names** + +Three names appear in the DRM path. They are not interchangeable: + + get_drm_configs — the dedicated DrmManager's only method + (matches DrmManagerProtocol) + get_channel_drm — the folded architecture's live-channel entry point + get_vod_drm — the folded architecture's VOD entry point + +New providers using the dedicated-manager architecture implement +`get_drm_configs` and leave the other two alone. Providers using the +folded architecture override `get_channel_drm` and/or `get_vod_drm` and +leave `get_drm_configs` alone. + +`StreamingProvider.get_drm(content_id, content_type=None)` is the public +method callers use; it dispatches to whichever architecture the provider +chose. `content_type` is a hint — pass it when you already know the +content type (e.g. the backend streaming route has already resolved the +item). Leave it `None` and the DRM source infers the type from its own +`content_id` grammar, which it knows better than the caller. + +**Which architecture to pick — a rule of thumb** + + Does the DRM call share state with the manifest fetch? + yes -> folded (Architecture 2) + no -> dedicated (Architecture 1) + +Dedicated is preferred when both work, because it keeps the DRM logic in +one place. Folded is the right call when the alternative would be passing +session or playback state between two managers anyway. + +## Errors + +Raise from `base.errors`: + + AuthError, CredentialsError, SessionExpiredError, + GeoBlockError, EntitlementError, AccountRestrictedError, + PlaybackRestrictedError, + NotFoundError, BadRequestError, + RateLimitError, ServerError, TransportError, + CatchupRequiredError, NotImplementedYetError, ConfigurationError + +Subclass when you need to carry payload: + + class MyProviderCatchupError(CatchupRequiredError): ... + +Existing callers that catch the base class catch your subclass too. +`ProviderError.__reduce__` preserves subclass payload across pickle and +`copy.deepcopy`, so payload-carrying subclasses are safe to pass through +queues and process boundaries. + +## VOD return type + +Every VOD navigation method returns `VodPage` (base/vod.py): + + return VodPage(entries=[...], next_cursor="2", total=120) + return VodPage(entries=[...]) # no pagination + return VodPage() # empty + +Pagination rule: use `page.has_more` — NOT `bool(page)` — to decide +whether to keep paging. `bool(page)` follows list semantics (empty page +is falsy, page with entries is truthy), which is correct for "are there +entries to display?" but wrong for pagination, because a page can have +zero entries and a non-None `next_cursor`. + +`total` may be `None`. It is informational; do not use it as an +end-of-list signal. `next_cursor is None` (equivalently, `not +page.has_more`) is the authoritative signal. + +## Lazy auth + +The template does NOT authenticate in `__init__`. The first +`build_headers()` / `get_access_token()` call triggers authentication. +This avoids network I/O in constructors and lets you create a provider +object for inspection without hitting the network. + +If your provider has a strong reason to authenticate eagerly (e.g. you +need to fail fast on bad credentials), do it in the provider's `__init__` +inside a try/except and log a warning — do not raise. \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/__init__.py b/lib/streaming_providers/providers/_template/__init__.py new file mode 100644 index 0000000..b7f4a68 --- /dev/null +++ b/lib/streaming_providers/providers/_template/__init__.py @@ -0,0 +1,15 @@ +# providers/_template/__init__.py +""" +Template scaffold -- not a real provider. + +This directory is a copy source for new providers, not a registrable +plugin. Its module does not import or export any StreamingProvider +subclass, so directory-based discovery (see streaming_providers/__init__.py) +never registers it, even if the leading-underscore skip rule is removed. + +To use: copy this directory to providers/{new_name}/ and rename the +classes. Change this __init__.py to import and export YourProvider once +the new provider is a real one. +""" + +__all__ = [] \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/auth.py b/lib/streaming_providers/providers/_template/auth.py new file mode 100644 index 0000000..258ba1d --- /dev/null +++ b/lib/streaming_providers/providers/_template/auth.py @@ -0,0 +1,220 @@ +# streaming_providers/providers/_template/auth.py +""" +{TODO: Provider name} authentication. + +Implements the three shared Auth methods (get_access_token, build_headers, +invalidate) and any optional extensions the provider needs. + +Auth is a protocol, not an ABC. See base/protocols.py for the runtime +shape; see ../_template/README.md for the contract. + +Constructor contract (recommended, not enforced): + __init__(*, http_manager, country, settings_manager=None, + credentials=None, **provider_opts) + +The provider's _build_auth() factory calls this. Extra kwargs are for +provider-specific state (device_id, client_version, platform, ...). +""" + +from typing import Any, Dict, Optional + +from ...base.utils.logger import logger + + +class YourProviderAuth: + """ + Authenticator for {TODO: provider name}. + + Not a subclass of any base class -- matches AuthProtocol by shape. + """ + + def __init__( + self, + *, + http_manager, + country: str, + settings_manager=None, + credentials=None, + **provider_opts, + ): + """ + Args: + http_manager: Shared HTTPManager instance (owned by provider). + country: Two-letter country code. + settings_manager: Base settings manager for credential storage. + May be None; the auth class must work without it. + credentials: Pre-supplied credentials (overrides storage). + **provider_opts: Provider-specific state (device_id, + client_version, platform, ...). Document what + you use; the base ignores everything here. + """ + self.http_manager = http_manager + self.country = country + self.settings_manager = settings_manager + self._credentials = credentials + self._cached_token = None + # TODO: store provider_opts you need, e.g.: + # self.device_id = provider_opts.get("device_id") or self._load_device_id() + + # ------------------------------------------------------------------ + # The three shared methods -- every provider implements these + # ------------------------------------------------------------------ + + def get_access_token(self, force_refresh: bool = False) -> str: + """ + Return the raw token string (no scheme prefix). + + If a cached token exists and is not near expiry, return it. Otherwise + authenticate, cache, and return. + """ + if ( + not force_refresh + and self._cached_token + and not self._cached_token.is_expired + ): + return self._cached_token.access_token + + token = self._perform_authentication() + self._cached_token = token + self._save_session(token) + return token.access_token + + def build_headers( + self, token: Optional[str] = None, **opts + ) -> Dict[str, str]: + """ + Return request-ready headers. + + If token is None, fetch it via get_access_token(). Providers add + their own non-auth headers (device id, client version, session + state, origin, referer) here. + """ + if token is None: + token = self.get_access_token() + + headers = { + "User-Agent": "TODO: your UA", + "Accept": "application/json", + # TODO: pick the auth scheme your provider uses: + # MoveTV "X-Auth-Token": token + # Magenta "Bff_token": token + # RTL+ "Authorization": f"Bearer {token}" + # HRTi "authorization": f"Client {token}" + # Discovery "Authorization": f"Bearer {token}" + session headers + "Authorization": f"Bearer {token}", + } + + # TODO: add non-auth headers the API requires. Examples: + # "X-Device-Id": self.device_id + # "X-Client-Version": self.client_version + # "Origin": self.config.base_website + # "Referer": f"{self.config.base_website}/" + # Discovery-style session state, Magenta-style guest ids, etc. also + # go here (built from self._session_state or equivalent). + + return headers + + def invalidate(self) -> None: + """ + Drop cached token and session state. Called after 401s. + + The next get_access_token() call must perform full re-authentication. + """ + self._cached_token = None + self._clear_session() + # TODO: clear provider-specific session state, e.g.: + # self._session_state = None + # self._disco_id = None + # self._cookies.clear() + + # ------------------------------------------------------------------ + # Provider-specific implementation + # ------------------------------------------------------------------ + + def _perform_authentication(self): + """ + Do the actual login HTTP call. Return a token object with at least + access_token, expires_in, and is_expired attributes. + + Return your custom AuthToken subclass if you have one, otherwise + return a BaseAuthToken. + """ + # TODO: + # 1. Ensure credentials (self._credentials, else load from + # settings_manager). + # 2. Build the login payload (from .models.YourCredentials if you + # have a custom one, else the plain username/password dict). + # 3. POST to the login endpoint via self.http_manager. + # 4. Parse the response into your token class. + # 5. Return the token. + raise NotImplementedError( + "YourProviderAuth._perform_authentication" + ) + + def _save_session(self, token) -> None: + """ + Persist the token via settings_manager (optional). + + Called after a successful authentication. If settings_manager is + None (e.g. in unit tests), do nothing. + """ + if self.settings_manager: + try: + self.settings_manager.save_token_data( + "TODO: provider_name", + token.to_dict(), + self.country, + ) + except Exception as e: + logger.debug(f"Could not persist token: {e}") + + def _clear_session(self) -> None: + """Clear any persisted session data.""" + if self.settings_manager: + try: + self.settings_manager.clear_token( + "TODO: provider_name", self.country + ) + except Exception: + pass + + # ------------------------------------------------------------------ + # Optional extensions -- uncomment and implement only if needed + # ------------------------------------------------------------------ + + # def get_scoped_token(self, scope: str, **opts) -> Optional[str]: + # """ + # Return a secondary token for the given scope, or None. + # + # RTL+ uses this for "bedrock" and "upfront" tokens. Providers + # without secondary tokens should leave this uncommented-out and + # returning None, or simply not define it at all (the base protocol + # only requires the three shared methods). + # """ + # return None + + # def get_session_context(self) -> Optional[Dict[str, Any]]: + # """ + # Return opaque session state needed by build_headers. + # + # Magenta returns {"device_id": ..., "session_id": ...} from this. + # Discovery returns the current session headers. Providers without + # session state leave this returning None. + # """ + # return None + + # def authorize_playback( + # self, content_id: str, **opts + # ) -> Dict[str, Any]: + # """ + # Provider-specific pre-playback step. + # + # HRTi's AuthorizeSession, MoveTV's live-source fetch, Discovery's + # playbackInfo POST, RTL+'s upfront token, Magenta's persona JWT + # retrieval. Return whatever your channel/vod managers need + # downstream. + # + # There is no fixed interface for this. The name is a convention; + # the shape is provider-specific. + # """ + # return {} \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/channel_manager.py b/lib/streaming_providers/providers/_template/channel_manager.py new file mode 100644 index 0000000..28a83eb --- /dev/null +++ b/lib/streaming_providers/providers/_template/channel_manager.py @@ -0,0 +1,55 @@ +# streaming_providers/providers/_template/channel_manager.py +""" +{TODO: Provider name} channel manager. + +Subclasses base.managers.ChannelManager. See ../_template/README.md. +""" + +from typing import Any, Dict, List, Optional + +from ...base.managers import ChannelManager +from ...base.models import Channel, DRMConfig +from ...base.utils.logger import logger + + +class YourChannelManager(ChannelManager): + """Fetches live channels for {TODO: provider name}.""" + + def __init__( + self, + *, + http_manager, + auth, + country, + config, + channels_cache: Optional[Dict] = None, + ): + # Forward ONLY the four required collaborators. Extra state goes + # on self below. A typo at the call site (e.g. channel_cache=) + # raises TypeError immediately. + super().__init__( + http_manager=http_manager, + auth=auth, + country=country, + config=config, + ) + self._channels_cache = ( + channels_cache if channels_cache is not None else {} + ) + + # ----- Abstract methods ----- + + def get_channels(self, **kw) -> List[Channel]: + raise NotImplementedError("YourChannelManager.get_channels") + + def get_channel_manifest( + self, content_id: str, **kw + ) -> Optional[str]: + raise NotImplementedError( + "YourChannelManager.get_channel_manifest" + ) + + # ----- Optional overrides ----- + + # def handles_content_id(self, content_id: str) -> bool: + # return content_id.isdigit() \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/constants.py b/lib/streaming_providers/providers/_template/constants.py new file mode 100644 index 0000000..f234545 --- /dev/null +++ b/lib/streaming_providers/providers/_template/constants.py @@ -0,0 +1,62 @@ +# streaming_providers/providers/_template/constants.py +""" +{TODO: Provider name} constants. + +All URLs, endpoint paths, static header values, and default parameters +live here so no other file contains magic strings. + +Structure: + * YourDefaults -- class-level constants. + * YourConfig -- instance config with override support, plus header + and URL builder methods. +""" + + +class YourDefaults: + PROVIDER_NAME = "TODO" + PROVIDER_LOGO = "TODO: url" + + BASE_URL = "TODO: https://..." + WEBSITE = "TODO: https://..." + + PATH_LOGIN = "/api/login" + PATH_CHANNELS = "/api/channels" + # ... etc + + USER_AGENT = "TODO" + TIMEOUT = 30 + + # Static values the API expects (partner ids, client versions, ...). + + +class YourConfig: + """ + Per-instance configuration. + + Attributes the template's provider.py relies on: + user_agent -- string, passed to _setup_http_manager. + timeout -- int seconds, passed to _setup_http_manager. + """ + + def __init__(self, config_dict: dict = None): + config = config_dict or {} + self.base_url = config.get("base_url", YourDefaults.BASE_URL) + self.user_agent = config.get("user_agent", YourDefaults.USER_AGENT) + self.timeout = config.get("timeout", YourDefaults.TIMEOUT) + # ... any other provider-specific config fields + + # ----- Header builders ----- + + def get_base_headers(self) -> dict: + return { + "User-Agent": self.user_agent, + "Accept": "application/json", + } + + # ----- URL builders ----- + + def login_url(self) -> str: + return f"{self.base_url}{YourDefaults.PATH_LOGIN}" + + def channels_url(self) -> str: + return f"{self.base_url}{YourDefaults.PATH_CHANNELS}" \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/drm_manager.py b/lib/streaming_providers/providers/_template/drm_manager.py new file mode 100644 index 0000000..186a318 --- /dev/null +++ b/lib/streaming_providers/providers/_template/drm_manager.py @@ -0,0 +1,152 @@ +# streaming_providers/providers/_template/drm_manager.py +""" +{TODO: Provider name} DRM manager (optional). + +Include this file only if the provider has DRM and you are using the +dedicated-manager architecture. Providers that fold DRM into their +channel/vod managers do not need this file -- see the README section +"DRM" for how the folded architecture is wired. + +There is no DRM base class, only a protocol (base/protocols.py, +DrmManagerProtocol). The shape is shared; the implementations vary enough +that a shared ABC would need more escape hatches than it saves. This file +is a scaffold and a document -- not an abstract class. + +How to structure a DRM manager for a new provider +------------------------------------------------- + +1. Class shape + class YourDrmManager: + def __init__( + self, + *, + http_manager, # shared HTTPManager from the provider + auth, # your Auth instance (matches AuthProtocol) + country, + config, + # plus whatever else this provider's DRM needs: + # playback_manager=None, session_cache=None, ... + ): + ... + + def get_drm_configs( + self, + content_id: str, + content_type: Optional[str] = None, + **opts, + ) -> List[DRMConfig]: + # If content_type is None, infer it from content_id grammar. + ... + +2. Wiring + In provider.py's __init__: + self.drm = self._build_drm() + + And: + def _build_drm(self) -> Optional[DrmManagerProtocol]: + return YourDrmManager(...) + + Return None from _build_drm() for providers with no DRM. + + The provider's implements_drm property is derived (see the template's + provider.py). implements_drm is True when either a dedicated DRM + manager is present, or the channel/vod managers override their DRM + methods. + + The provider's get_drm() delegates to this manager when present. + +3. Reference implementations + The four existing patterns differ enough that picking the closest + match and adapting it is faster than designing from scratch. Read + the referenced files before writing yours; the description below + is a summary, the code is the source of truth. + + Pattern A -- per-content upfront token + Files: providers/rtlplus/provider.py (DRM flow) + providers/rtlplus/auth.py (upfront token) + providers/lib_drmtoday.py (the shared library) + + Summary: fetch the layout for the content_id, extract the DRM + asset config, call auth.get_scoped_token("upfront", content_id, + uid), then lib_drmtoday.create_drmtoday_configs(...). Returns + [widevine, playready] in one call. + + Requires an authenticated user (profile selected). The upfront + token is per-content, not per-session. + + Pattern B -- construct licence URL from token claims + Files: providers/magentaeu/vod_manager.py (DRM flow) + providers/magentaeu/provider.py (live DRM flow) + providers/magentaeu/auth.py (token claims) + providers/lib_theplatform.py (URL + config builders) + + Summary: resolve the media item to get release_pid (via the + /media endpoint), read persona_jwt from the access token claims, + read account_uri from /user/account, then + lib_theplatform.build_licence_url(...) and + lib_theplatform.build_widevine_drm_config(...). Returns + [widevine]. + + The licence URL is CONSTRUCTED, not fetched -- three data + sources: /media response, token claims, /user/account. + + Pattern C -- DRM arrives with the playback response + Files: providers/discovery/playback_manager.py (playbackInfo + + DRM extraction in one place) + providers/discovery/constants.py (platform_os -> DRM + system mapping) + + Summary: during get_manifest, POST playbackInfo and cache the + response. get_drm() is a cache lookup; no separate DRM call. + The response carries both widevine and playready schemes with + licenseUrl each; pick the one matching the active platform_os. + Returns [widevine] or [playready]. + + DRM and manifest share the playbackInfo cache. Do NOT re-fetch. + drm.expirationDate governs cache invalidation. + + Pattern D -- session id becomes a base64 auth blob + Files: providers/hrti/provider.py (both live and VOD DRM flows) + providers/hrti/auth.py (session authorize + licence + data generation) + providers/lib_drmtoday.py (the shared library) + + Summary: auth.authorize_session(...) returns a session dict + carrying "DrmId". auth.get_license_data(DrmId) returns a + base64-encoded JSON blob. Then + lib_drmtoday.create_drmtoday_widevine_config(..., auth_header_name= + "dt-custom-data"). Returns [widevine]. + + authorize_session must run before get_drm; the provider caches + the session so the manifest step and the DRM step share it. The + base64 blob is passed as a header, not a query param. + +4. What to cache and where + Every existing provider caches DRM material somewhere: + * RTL+ no cache; the upfront token is refetched per playback. + * Magenta the account_info lives on the auth token, not the + DRM manager. + * Discovery the whole playbackInfo response lives in the + playback_cache, keyed by edit_id, TTL from + drm.expirationDate. + * HRTi the session dict lives in a session cache, populated + during the manifest step. + + Pick whichever fits. If DRM shares state with manifest, put the cache + on the provider and pass it into both managers, matching the pattern + the other managers already use. + +5. Testing + DRM managers take http_manager and auth by injection, so they are + testable without network access. Provide a mock http_manager that + returns canned licence responses, and a mock auth that returns a + canned token with the right claims. No real DRM call needed. + +6. Fields on DRMConfig + If your provider returns something not covered by the existing + DRMConfig fields, do NOT extend DRMConfig. Instead: + * If it's a header, add it to license.req_headers. + * If it's a request body format, use license.req_data. + * If it's something structurally new, raise it before extending the + shared model -- every provider inherits changes. +""" \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/epg_manager.py b/lib/streaming_providers/providers/_template/epg_manager.py new file mode 100644 index 0000000..75f7990 --- /dev/null +++ b/lib/streaming_providers/providers/_template/epg_manager.py @@ -0,0 +1,46 @@ +# streaming_providers/providers/_template/epg_manager.py +""" +{TODO: Provider name} EPG manager. + +Subclasses base.managers.EpgManager. See ../_template/README.md. +""" + +from datetime import datetime +from typing import Dict, List, Optional, Tuple + +from ...base.managers import EpgManager +from ...base.models.epg_models import EPGEntry, EPGProgramDetails +from ...base.utils.logger import logger + + +class YourEpgManager(EpgManager): + """EPG for {TODO: provider name}.""" + + @property + def epg_window(self) -> Tuple[int, int]: + """(past_days, future_days). (0, 0) means no EPG support.""" + return 0, 0 # TODO: e.g. (2, 7) + + def get_epg( + self, + channel_id: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + **kw, + ) -> List[EPGEntry]: + raise NotImplementedError("YourEpgManager.get_epg") + + # ----- Optional overrides ----- + + # def get_epg_grid( + # self, + # channel_ids: List[str], + # start_time: Optional[datetime] = None, + # end_time: Optional[datetime] = None, + # **kw, + # ) -> Dict[str, List[EPGEntry]]: + # # Override if the provider has a native batch endpoint. + # return super().get_epg_grid( + # channel_ids, start_time=start_time, + # end_time=end_time, **kw + # ) \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/models.py b/lib/streaming_providers/providers/_template/models.py new file mode 100644 index 0000000..51304cc --- /dev/null +++ b/lib/streaming_providers/providers/_template/models.py @@ -0,0 +1,146 @@ +# streaming_providers/providers/_template/models.py +""" +{TODO: Provider name} models. + +Only needed if your provider requires: + * A custom Channel subclass (extra fields on channels — see MoveTV's + MoveTVChannel and Discovery's DiscoveryChannel). + * A custom AuthToken subclass (extra claims on the token — most existing + providers have one: RTLPlusAuthToken, MagentaAuthToken, MoveTVAuthToken, + DiscoveryAuthToken, HRTiAuthToken). + * A custom Credentials subclass (unusual auth payload — see HRTi's + HRTiCredentials). + +If your provider can be expressed with the base Channel / BaseAuthToken and +a plain UserPasswordCredentials, you don't need this file. + +Rules +----- +* When overriding to_dict(), call super().to_dict() and add your fields. + Both Channel.to_dict() and BaseAuthToken.to_dict() chain correctly. +* Custom Channel subclasses are returned from ChannelManager.get_channels() + as-is; nothing in the base inspects the concrete type. +* Custom AuthToken subclasses are returned from your Auth's + _perform_authentication(); the base never inspects their type beyond the + attributes it needs (access_token, expires_in, is_expired). +""" + +# --------------------------------------------------------------------------- +# Example: custom Channel subclass +# --------------------------------------------------------------------------- + +# from dataclasses import dataclass +# from typing import Any, Dict +# +# from ...base.models import Channel +# +# +# @dataclass +# class YourChannel(Channel): +# """ +# Channel with provider-specific extra fields. +# +# Keep the base class's field names and defaults; add new fields after +# them so positional construction still works if any caller relies on it. +# Keyword construction is preferred. +# """ +# +# # Provider-specific extras. +# your_field: str = "" +# your_expires_at: float = 0.0 +# +# def to_dict(self) -> Dict[str, Any]: +# result = super().to_dict() +# result["YourField"] = self.your_field +# result["YourExpiresAt"] = self.your_expires_at +# return result + + +# --------------------------------------------------------------------------- +# Example: custom AuthToken subclass +# --------------------------------------------------------------------------- + +# from typing import Any, Dict, Optional +# +# from ...base.auth.base_auth import BaseAuthToken +# +# +# class YourAuthToken(BaseAuthToken): +# """ +# AuthToken with provider-specific fields. +# +# BaseAuthToken.__init__ takes: +# access_token, token_type, expires_in, issued_at, +# refresh_token=None, refresh_expires_in=0 +# +# Add your fields as keyword args with sensible defaults. +# """ +# +# def __init__( +# self, +# *, +# access_token: str, +# token_type: str, +# expires_in: int, +# issued_at: float, +# your_extra: str = "", +# refresh_token: Optional[str] = None, +# refresh_expires_in: int = 0, +# ): +# super().__init__( +# access_token=access_token, +# token_type=token_type, +# expires_in=expires_in, +# issued_at=issued_at, +# refresh_token=refresh_token, +# refresh_expires_in=refresh_expires_in, +# ) +# self.your_extra = your_extra +# +# def to_dict(self) -> Dict[str, Any]: +# result = super().to_dict() +# result["your_extra"] = self.your_extra +# return result +# +# @classmethod +# def from_dict(cls, data: Dict[str, Any]) -> "YourAuthToken": +# """Reconstruct from a persisted dict. Used by _load_session().""" +# return cls( +# access_token=data["access_token"], +# token_type=data.get("token_type", "Bearer"), +# expires_in=data.get("expires_in", 0), +# issued_at=data.get("issued_at", 0), +# your_extra=data.get("your_extra", ""), +# refresh_token=data.get("refresh_token"), +# refresh_expires_in=data.get("refresh_expires_in", 0), +# ) + + +# --------------------------------------------------------------------------- +# Example: custom Credentials subclass +# --------------------------------------------------------------------------- + +# from dataclasses import dataclass +# from typing import Any, Dict +# +# from ...base.auth.credentials import UserPasswordCredentials +# +# +# @dataclass +# class YourCredentials(UserPasswordCredentials): +# """ +# Credentials with a provider-specific payload shape. +# +# Only needed when the provider's login payload isn't the usual +# {username, password} shape (HRTi's grant_access takes +# {Username, Password, OperatorReferenceId}, for example). +# """ +# +# operator_reference_id: str = "default" +# +# def to_auth_payload(self) -> Dict[str, Any]: +# return { +# "Username": self.username, +# "Password": self.password, +# "OperatorReferenceId": self.operator_reference_id, +# } \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/provider.py b/lib/streaming_providers/providers/_template/provider.py new file mode 100644 index 0000000..8a8d789 --- /dev/null +++ b/lib/streaming_providers/providers/_template/provider.py @@ -0,0 +1,249 @@ +# streaming_providers/providers/_template/provider.py +""" +{TODO: Provider name} orchestrator. + +Owns shared resources (http_manager, caches, auth, managers) and exposes +the public StreamingProvider interface. + +Subclasses the existing StreamingProvider. Does NOT subclass any new +base class. Authentication is lazy -- no network I/O in __init__. +""" + +from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple + +from ...base.errors import NotFoundError +from ...base.managers import ChannelManager, VodManager +from ...base.models.proxy_models import ProxyConfig +from ...base.protocols import DrmManagerProtocol +from ...base.provider import StreamingProvider +from ...base.utils.logger import logger + +from .auth import YourProviderAuth +from .channel_manager import YourChannelManager +from .constants import YourConfig +# from .vod_manager import YourVodManager +# from .epg_manager import YourEpgManager +# from .drm_manager import YourDrmManager + + +class YourProvider(StreamingProvider): + """{TODO: provider name} streaming provider.""" + + PROVIDER_LABEL: ClassVar[str] = "TODO: display label" + PROVIDER_LOGO: ClassVar[str] = "TODO: logo url" + SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["user_credentials"] + SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["TODO", "country", "codes"] + + def __init__( + self, + country: str = "TODO", + config: Optional[Dict] = None, + proxy_config: Optional[ProxyConfig] = None, + settings_manager=None, + **kwargs, + ): + super().__init__(country) + + config = config or {} + self.config = YourConfig(config) + + # 1. HTTP manager. + self.http_manager = self._setup_http_manager( + provider_name="TODO: provider_name", + proxy_config=proxy_config, + user_agent=self.config.user_agent, + timeout=self.config.timeout, + ) + + # 2. Auth (protocol, not ABC). Lazy -- no network call here. + self.auth = self._build_auth(settings_manager) + + # 3. Provider-owned caches. Managers borrow these by reference. + self._channels_cache: Dict = {} + self._playback_cache: Dict = {} + + # 4. Managers. + self.channels = self._build_channels() + self.vod = self._build_vod() + self.epg = self._build_epg() + self.drm = self._build_drm() + + # ----- Factory methods ----- + + def _build_auth(self, settings_manager): + return YourProviderAuth( + http_manager=self.http_manager, + country=self.country, + settings_manager=settings_manager, + ) + + def _build_channels(self): + return YourChannelManager( + http_manager=self.http_manager, + auth=self.auth, + country=self.country, + config=self.config, + channels_cache=self._channels_cache, + ) + + def _build_vod(self): + # TODO: return YourVodManager( + # http_manager=self.http_manager, + # auth=self.auth, + # country=self.country, + # config=self.config, + # playback_cache=self._playback_cache, + # ) + return None + + def _build_epg(self): + return None + + def _build_drm(self) -> Optional[DrmManagerProtocol]: + """ + Return a dedicated DRM manager, or None. + + Two supported architectures: + + * Dedicated manager: return a class matching DrmManagerProtocol + here; the provider's get_drm() delegates to it. + + * Folded into managers: leave this returning None, and instead + override get_channel_drm() on your ChannelManager and/or + get_vod_drm() on your VodManager. The provider's get_drm() + falls back to routing to those. + + New providers should prefer the dedicated manager (see the README + section "DRM"). The folded style exists for providers whose DRM + call shares significant state with the manifest step. + + See providers/_template/drm_manager.py for the four existing + patterns (RTL+ upfront token, Magenta constructed URL, Discovery + playbackInfo, HRTi session id). + """ + # TODO: return YourDrmManager( + # http_manager=self.http_manager, + # auth=self.auth, + # country=self.country, + # config=self.config, + # # ... provider-specific collaborators + # ) + return None + + # ----- Capability flags (derived from manager presence) ----- + + @property + def implements_channels(self) -> bool: + return self.channels is not None + + @property + def implements_vod(self) -> bool: + return self.vod is not None + + @property + def implements_epg(self) -> bool: + return self.epg is not None + + @property + def implements_drm(self) -> bool: + """ + True when this provider can produce DRM configurations. + + Derived from either source of DRM: + * a dedicated DRM manager (_build_drm returned a class), OR + * a ChannelManager that overrides get_channel_drm, OR + * a VodManager that overrides get_vod_drm. + + The base classes' defaults return []; we detect overrides by + comparing the bound method against the base class's method. This + is what makes the flag correct for the folded architecture. + """ + if self.drm is not None: + return True + + folded_channels = ( + self.channels is not None + and type(self.channels).get_channel_drm + is not ChannelManager.get_channel_drm + ) + folded_vod = ( + self.vod is not None + and type(self.vod).get_vod_drm + is not VodManager.get_vod_drm + ) + return folded_channels or folded_vod + + # ----- Router ----- + + def _route(self, content_id: str, attempts: List[Tuple[Any, Callable]]): + """ + Try managers in order, using handles_content_id() to skip those + that declare they don't handle the id. + + Distinguishes three outcomes: + * manager returned a truthy result -> return it + * manager returned None / [] / falsy -> try next manager + * manager raised NotFoundError -> remember it, try next + + If nobody resolved and a NotFoundError was seen, re-raise it -- + that's "the content existed in some manager's domain but is gone", + distinct from "nobody handles this id at all" (which returns None). + """ + last_not_found: Optional[NotFoundError] = None + for manager, call in attempts: + if manager is None or not manager.handles_content_id(content_id): + continue + try: + result = call(manager) + except NotFoundError as e: + last_not_found = e + continue + if result: + return result + if last_not_found is not None: + raise last_not_found + return None + + # ----- Public delegations ----- + + def get_channels(self, **kw): + if self.channels is None: + return [] + return self.channels.get_channels(**kw) + + def get_manifest(self, content_id: str, **kw) -> Optional[str]: + return self._route(content_id, [ + (self.channels, lambda m: m.get_channel_manifest(content_id, **kw)), + (self.vod, lambda m: m.get_vod_manifest(content_id, **kw)), + ]) + + def get_drm( + self, + content_id: str, + content_type: Optional[str] = None, + **kw, + ) -> List: + """ + Return DRM configuration(s) for the given content. + + content_type is an optional hint. When None (the default), the + DRM source infers the type from its own content_id grammar -- + which is the preferred mode, since the source knows its own + grammar better than the caller does. Callers that already know + the type (e.g. the backend's streaming route) should pass it + explicitly. + + If a dedicated DRM manager is configured, delegate to it. Otherwise + fall back to per-manager DRM (channel manager's get_channel_drm, + VOD manager's get_vod_drm) via the router. + """ + if self.drm is not None: + return self.drm.get_drm_configs( + content_id, + content_type=content_type, + **kw, + ) + return self._route(content_id, [ + (self.channels, lambda m: m.get_channel_drm(content_id, **kw)), + (self.vod, lambda m: m.get_vod_drm(content_id, **kw)), + ]) or [] \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/vod_manager.py b/lib/streaming_providers/providers/_template/vod_manager.py new file mode 100644 index 0000000..c4ee592 --- /dev/null +++ b/lib/streaming_providers/providers/_template/vod_manager.py @@ -0,0 +1,67 @@ +# streaming_providers/providers/_template/vod_manager.py +""" +{TODO: Provider name} VOD manager. + +Subclasses base.managers.VodManager. Document the content_id grammar here. + +See ../_template/README.md for the contract. +""" + +from typing import Any, Dict, Optional + +from ...base.managers import VodManager +from ...base.models import DRMConfig +from ...base.vod import VodPage +from ...base.utils.logger import logger + + +class YourVodManager(VodManager): + """ + VOD catalogue navigation for {TODO: provider name}. + + content_id grammar (TODO: fill in): + + "" root + "folder_" browse a folder + "details_" a single item + """ + + def __init__( + self, + *, + http_manager, + auth, + country, + config, + playback_cache: Optional[Dict] = None, + ): + super().__init__( + http_manager=http_manager, + auth=auth, + country=country, + config=config, + ) + self._playback_cache = ( + playback_cache if playback_cache is not None else {} + ) + + # ----- Abstract methods ----- + + def get_vod_category( + self, + content_id: str = "", + cursor: Optional[str] = None, + page_size: int = 24, + **kw, + ) -> VodPage: + raise NotImplementedError("YourVodManager.get_vod_category") + + def get_vod_manifest( + self, content_id: str, **kw + ) -> Optional[str]: + raise NotImplementedError("YourVodManager.get_vod_manifest") + + # ----- Optional overrides ----- + + # def handles_content_id(self, content_id: str) -> bool: + # return content_id.startswith(("details_", "clip_")) \ No newline at end of file