diff --git a/lib/streaming_providers/providers/_template/README.md b/lib/streaming_providers/providers/_template/README.md index c8c643c..e9feb85 100644 --- a/lib/streaming_providers/providers/_template/README.md +++ b/lib/streaming_providers/providers/_template/README.md @@ -34,6 +34,23 @@ and fill in the stubs. Read this file first — it explains the contract. `BaseAuthToken` is an ABC with an abstract `to_dict()`. A custom Channel subclass is optional. See "Models" below. +## Files in this template + + provider.py orchestrator (required) + auth.py token + credential surface (if the provider authenticates) + models.py AuthToken subclass (mandatory with auth); Channel/Credentials examples + constants.py URLs, paths, headers, parameter names, PROVIDER_NAME + channel_manager.py ChannelManager + module-scope content-id parsers + vod_manager.py VodManager + epg_manager.py EpgManager + recordings_manager.py RecordingsManager + favorites_manager.py FavoritesManager + bookmarks_manager.py BookmarksManager + catchup_manager.py CatchupManager + drm_manager.py dedicated DRM manager (a protocol, not an ABC) + patterns + +Delete the files for capabilities the provider does not have. + ## Provider class members Every provider declares these. Some are abstract (must be implemented by @@ -647,7 +664,7 @@ Only one of them participates in `_route` for the prefix, and the choice is which manager owns the manifest fetch. The provider's `get_manifest` is the authoritative declaration of that choice. -Do not give the same prefix two router branches. Two branches for thesame prefix means two code paths for the same content, and they will +Do not give the same prefix two router branches. Two branches for the same prefix means two code paths for the same content, and they will drift. ### Sentinels for unused ABC parameters @@ -904,6 +921,11 @@ it may be None), then falls back to `CredentialManager`. The auth class's `_resolve_credentials()` calls that helper only if `self._credentials` is None. +The template's `auth.py` implements this whole surface +(`has_credentials`, `set_credentials`, `clear_credentials`, +`_load_stored_credentials`, `_ensure_credentials`) and the provider's +`_build_auth()` forwards `credentials=self._credentials`. Keep both. + **Re-read credentials on every authenticate, not just at construction.** A user can store credentials through the UI at any time after the provider was constructed. If your auth class caches @@ -1155,7 +1177,9 @@ Bookmarks: `update_bookmark` is called on every playback stop / pause, often consecutively for the same position. Providers should tolerate repeated no-op writes to the same position without erroring or firing spurious events. `position_seconds = -1` marks the content as -completed. Deleting a non-existent bookmark raises `KeyError`. +completed. Deleting a non-existent bookmark raises `KeyError`. Backend +failures raise a `ProviderError` subclass from `base.errors`, like every +other manager. ## Conventions diff --git a/lib/streaming_providers/providers/_template/__init__.py b/lib/streaming_providers/providers/_template/__init__.py index b7f4a68..d8ba01d 100644 --- a/lib/streaming_providers/providers/_template/__init__.py +++ b/lib/streaming_providers/providers/_template/__init__.py @@ -8,8 +8,15 @@ 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. +classes. Then replace the body of this __init__.py with: + + from .provider import YourProvider # renamed + + __all__ = ["YourProvider"] + +The registry derives the plugin name from the class name +(`cls.__name__.lower().replace("provider", "")`), so the class name must +match the directory name you chose. """ __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 index 258ba1d..5541ba0 100644 --- a/lib/streaming_providers/providers/_template/auth.py +++ b/lib/streaming_providers/providers/_template/auth.py @@ -3,23 +3,53 @@ {TODO: Provider name} authentication. Implements the three shared Auth methods (get_access_token, build_headers, -invalidate) and any optional extensions the provider needs. +invalidate), the credential surface (has_credentials, set_credentials, +clear_credentials), 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. +shape; see ../_template/README.md ("The Auth protocol") 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, ...). +Credentials source priority (README, "Credentials"): + 1. constructor argument (CLI, tests) + 2. injected settings_manager (may be None -- the registry usually + constructs providers WITHOUT one) + 3. CredentialManager (direct credentials.json read) -- the path that + actually works in the normal runtime + 4. fallback credentials (anonymous/free tier), if any +Credentials are re-read on every authenticate, so a user who stores them +after the provider was constructed does not need an app restart. + +Deviations to document HERE (module docstring) if your provider has them: + * Token NOT in a header (query param / body field): build_headers() + returns base headers only; callers attach the token via + with_token(url, param=...) / auth_body(). Put the param names in + constants.py (they can differ per endpoint) and add a one-line + comment at every manager call site that uses build_headers(). + * Content-Type quirks (e.g. a login endpoint that needs text/plain with + a JSON body): send data=json.dumps(payload) with the header set for + THAT call only, and comment why, or someone will "fix" it. + +Thread safety: the host is multi-threaded. get_access_token() and the +other stateful accessors hold an RLock so two threads on a cold cache do +not run the (multi-step) login twice. Providers whose login is a single +HTTP call can drop the lock. """ +import threading from typing import Any, Dict, Optional +from ...base.auth.credential_manager import CredentialManager +from ...base.auth.credentials import UserPasswordCredentials +from ...base.errors import CredentialsError from ...base.utils.logger import logger +from .constants import YourDefaults +from .models import YourAuthToken + class YourProviderAuth: """ @@ -35,15 +65,17 @@ class YourProviderAuth: country: str, settings_manager=None, credentials=None, + config=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). + settings_manager: Base settings manager. May be None; the auth + class must work without it. + credentials: Pre-supplied credentials (source #1). + config: The provider's YourConfig (URLs, base headers). **provider_opts: Provider-specific state (device_id, client_version, platform, ...). Document what you use; the base ignores everything here. @@ -51,11 +83,15 @@ class YourProviderAuth: self.http_manager = http_manager self.country = country self.settings_manager = settings_manager + self.config = config self._credentials = credentials - self._cached_token = None + self._lock = threading.RLock() # TODO: store provider_opts you need, e.g.: # self.device_id = provider_opts.get("device_id") or self._load_device_id() + # Optional persistence: restore a stored token (no network I/O). + self._cached_token = self._load_session() + # ------------------------------------------------------------------ # The three shared methods -- every provider implements these # ------------------------------------------------------------------ @@ -64,20 +100,21 @@ class YourProviderAuth: """ 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 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 + with self._lock: + 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 + 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 @@ -92,25 +129,26 @@ class YourProviderAuth: 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}", - } + headers = ( + self.config.get_base_headers() + if self.config is not None + else {"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 + 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}/" + # "Origin": ..., "Referer": ... # Discovery-style session state, Magenta-style guest ids, etc. also - # go here (built from self._session_state or equivalent). + # go here. return headers @@ -118,88 +156,216 @@ class YourProviderAuth: """ Drop cached token and session state. Called after 401s. - The next get_access_token() call must perform full re-authentication. + The next get_access_token() call must perform full + re-authentication. Callers: on a 401, call invalidate() and retry + the request once -- never in a loop. """ - 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() + with self._lock: + self._cached_token = None + self._clear_session() + # TODO: clear provider-specific session state, e.g.: + # self._session_state = None + # self._cookies.clear() + + # ------------------------------------------------------------------ + # Credential surface (providers with user credentials) + # + # Providers WITHOUT user credentials: has_credentials() returns True, + # set_/clear_credentials() are no-ops returning False, and + # _ensure_credentials() is not needed in _perform_authentication(). + # ------------------------------------------------------------------ + + def has_credentials(self) -> bool: + """True if this auth can authenticate right now.""" + if self._credentials and self._credentials.validate(): + return True + fresh = self._load_stored_credentials() + if fresh and fresh.validate(): + return True + fallback = self.get_fallback_credentials() + return bool(fallback and fallback.validate()) + + def set_credentials(self, username: str, password: str) -> bool: + """Persist credentials (called by the settings UI).""" + if not self.settings_manager: + return False + try: + self.settings_manager.save_provider_credentials( + YourDefaults.PROVIDER_NAME, + UserPasswordCredentials(username, password), + self.country, + ) + except Exception as e: + logger.warning(f"Could not store credentials: {e}") + return False + with self._lock: + self._credentials = None # force a re-read on next login + self.invalidate() + return True + + def clear_credentials(self) -> bool: + """Clear stored credentials and drop the cached token.""" + try: + if self.settings_manager: + self.settings_manager.clear_provider_credentials( + YourDefaults.PROVIDER_NAME, self.country + ) + else: + CredentialManager().delete_credentials( + YourDefaults.PROVIDER_NAME, self.country + ) + except Exception as e: + logger.warning(f"Could not clear credentials: {e}") + return False + with self._lock: + self._credentials = None + self.invalidate() + return True + + def get_fallback_credentials(self): + """ + Credentials for an anonymous / limited free tier, or None. + + Override for providers that work without user configuration. + """ + return None + + def _load_stored_credentials(self): + """Sources #2 and #3: settings_manager first, then CredentialManager.""" + if self.settings_manager and hasattr( + self.settings_manager, "get_provider_credentials" + ): + try: + creds = self.settings_manager.get_provider_credentials( + YourDefaults.PROVIDER_NAME, self.country + ) + if creds: + return creds + except Exception as e: + logger.debug(f"settings_manager credentials failed: {e}") + try: + # Covers both the country-nested and flat storage layouts. + return CredentialManager().load_credentials( + YourDefaults.PROVIDER_NAME, self.country + ) + except Exception as e: + logger.debug(f"CredentialManager load failed: {e}") + return None + + def _ensure_credentials(self) -> bool: + """ + Make self._credentials valid, re-reading storage if needed. + + Call at the START of _perform_authentication(). Without it the + auth class silently depends on the caller having passed + credentials at construction -- which the registry never does. + """ + if self._credentials and self._credentials.validate(): + return True + fresh = self._load_stored_credentials() + if fresh and fresh.validate(): + self._credentials = fresh + return True + self._credentials = self.get_fallback_credentials() + return self._credentials is not None and self._credentials.validate() # ------------------------------------------------------------------ # Provider-specific implementation # ------------------------------------------------------------------ - def _perform_authentication(self): + def _perform_authentication(self) -> YourAuthToken: """ - Do the actual login HTTP call. Return a token object with at least - access_token, expires_in, and is_expired attributes. + Do the actual login HTTP call and return a YourAuthToken. - Return your custom AuthToken subclass if you have one, otherwise - return a BaseAuthToken. + (A concrete AuthToken subclass is mandatory: BaseAuthToken is an + ABC. See models.py.) """ - # 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. + if not self._ensure_credentials(): + raise CredentialsError( + f"no credentials available for {YourDefaults.PROVIDER_NAME}" + ) + payload = self._build_login_payload(self._credentials) + resp = self.http_manager.post( + self._login_url(), json=payload, headers=self._login_headers() + ) + return self._create_token_from_response(resp.json()) + + def _login_url(self) -> str: + return self.config.login_url() + + def _login_headers(self) -> Dict[str, str]: + # Base headers only: build_headers() would try to fetch a token. + return self.config.get_base_headers() + + def _build_login_payload(self, credentials) -> Dict[str, Any]: + # Custom credentials classes provide to_auth_payload(). + # TODO: adapt to your provider's login payload. + return { + "username": credentials.username, + "password": credentials.password, + } + + def _create_token_from_response(self, data: Dict[str, Any]) -> YourAuthToken: + # TODO: parse the login response. Check base/auth/base_auth.py for + # any additional required BaseAuthToken fields. raise NotImplementedError( - "YourProviderAuth._perform_authentication" + "YourProviderAuth._create_token_from_response" ) - def _save_session(self, token) -> None: - """ - Persist the token via settings_manager (optional). + # ------------------------------------------------------------------ + # Session persistence (OPTIONAL) + # + # Skip it for providers with cheap re-auth (opaque token, no refresh + # flow -- simpliTV does). Keep it for expensive flows (multi-step, + # rate-limited, device codes). If you skip it, delete _load_session / + # _save_session / _clear_session and the call in __init__. + # ------------------------------------------------------------------ - 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 _load_session(self) -> Optional[YourAuthToken]: + if not self.settings_manager: + return None + try: + stored = self.settings_manager.load_token_data( + YourDefaults.PROVIDER_NAME, self.country + ) + return YourAuthToken.from_dict(stored) if stored else None + except Exception as e: + logger.debug(f"Could not restore stored token: {e}") + return None + + def _save_session(self, token) -> None: + if not self.settings_manager: + return + try: + self.settings_manager.save_token_data( + YourDefaults.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 + if not self.settings_manager: + return + try: + self.settings_manager.clear_token( + YourDefaults.PROVIDER_NAME, self.country + ) + except Exception as e: + logger.debug(f"Could not clear stored token: {e}") # ------------------------------------------------------------------ # 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). - # """ + # """Secondary token for the given scope (RTL+: bedrock / upfront).""" # 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. + # Opaque session state needed by build_headers. Magenta returns + # {"device_id": ..., "session_id": ...}; Discovery the current + # session headers. # """ # return None @@ -207,14 +373,14 @@ class YourProviderAuth: # 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. + # Provider-specific pre-playback step (HRTi AuthorizeSession, + # MoveTV live-source fetch, Discovery playbackInfo POST, RTL+ + # upfront token, Magenta persona JWT). No fixed interface; the + # name is a convention, the shape is provider-specific. # """ - # return {} \ No newline at end of file + # return {} + + # def with_token(self, url: str, param: Optional[str] = None) -> str: + # """Token-in-URL providers: append the token. Take the parameter + # name from constants.py (YourDefaults.TOKEN_PARAM), never hardcode.""" + # ... \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/bookmarks_manager.py b/lib/streaming_providers/providers/_template/bookmarks_manager.py index 8f36368..2ea9155 100644 --- a/lib/streaming_providers/providers/_template/bookmarks_manager.py +++ b/lib/streaming_providers/providers/_template/bookmarks_manager.py @@ -23,9 +23,17 @@ update_bookmark is called on every playback stop / pause, often consecutively for the same position. Providers should tolerate repeated no-op writes to the same position without erroring or firing spurious events. + +Errors +------ +Backend failures raise a ProviderError subclass from base.errors +(ServerError, TransportError, ...), per the README's "Errors" section. +NOTE: earlier revisions of this template said RuntimeError. If +ProviderBookmarksMixin still documents RuntimeError, align the two +(callers catching RuntimeError would miss ProviderError). """ -from typing import Any, List, Optional +from typing import List, Optional from ...base.managers import BookmarksManager from ...base.models.bookmark import Bookmark, ContentType @@ -74,7 +82,7 @@ class YourBookmarksManager(BookmarksManager): position_seconds = -1 marks the content as completed. - Raises RuntimeError if the provider rejects. + Raises a ProviderError subclass if the provider rejects. """ raise NotImplementedError("YourBookmarksManager.update_bookmark") @@ -83,7 +91,7 @@ class YourBookmarksManager(BookmarksManager): Delete a bookmark. Raises: - KeyError: if no bookmark exists for content_id. - RuntimeError: on backend failure. + KeyError: if no bookmark exists for content_id. + ProviderError: (a subclass) on backend failure. """ raise NotImplementedError("YourBookmarksManager.delete_bookmark") \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/catchup_manager.py b/lib/streaming_providers/providers/_template/catchup_manager.py index fddf9b9..4c36448 100644 --- a/lib/streaming_providers/providers/_template/catchup_manager.py +++ b/lib/streaming_providers/providers/_template/catchup_manager.py @@ -22,6 +22,11 @@ Magenta EU's provider (providers/magentaeu/provider.py) implements catchup by appending start/end query parameters to the live manifest URL via build_catchup_url(). +simpliTV's catchup only takes a start bound: it passes end_time=None +through and documents that it ignores it. The router parses the +"catchup:@" id (an explicit branch above _route) and +hands the parsed arguments to this manager. + HRTi has no catchup -- its VOD and EPG are separate domains, and authorize_session's session id is not reused for timeshift. @@ -30,22 +35,32 @@ State sharing The catchup step often shares state with the channel manager (the live manifest URL) or the EPG manager (the epg_id for the requested window). Pass those collaborators as explicit keyword-only arguments rather than -reaching back to the provider. +reaching back to the provider (the provider's _build_catchup does this). Do NOT fall back to the live manifest ------------------------------------- If get_catchup_manifest cannot resolve catchup for the given window, return None. Do not return the live manifest URL as a "catchup" manifest -- the DRM pipeline would extract PSSH from the live stream, -which may differ from the catchup stream's encryption context. +which may differ from the catchup stream's encryption context. Callers +that want the live manifest on failure call provider.get_manifest(). + +end_time is Optional[int] +------------------------- +If the provider's API takes only a start bound, accept None and document +that it is ignored. If the API needs both bounds, raise BadRequestError +on None. Never pass a sentinel (0, start_time + 1800) when the ABC +accepts None. """ -from typing import Any, Dict, List, Optional +from typing import List, Optional from ...base.managers import CatchupManager from ...base.models import DRMConfig from ...base.utils.logger import logger +# from ...base.errors import BadRequestError + class YourCatchupManager(CatchupManager): """Catchup for {TODO: provider name}.""" @@ -68,7 +83,7 @@ class YourCatchupManager(CatchupManager): ) # Common collaborators. Catchup often needs one or both. # - channels: for resolving a channel's live manifest URL - # (Magenta) or its stream uid (MoveTV). + # (Magenta, simpliTV) or its stream uid (MoveTV). # - epg: for resolving an epg_id from a start_time # (MoveTV). self._channels = channels @@ -87,13 +102,16 @@ class YourCatchupManager(CatchupManager): self, content_id: str, start_time: int, - end_time: int, + end_time: Optional[int] = None, epg_id: Optional[str] = None, **kw, ) -> Optional[str]: """ Return the catchup manifest URL, or None if not resolvable. + start_time / end_time are integer epoch seconds. end_time may be + None (see module docstring). + Do NOT fall back to the live manifest URL here. """ raise NotImplementedError("YourCatchupManager.get_catchup_manifest") @@ -104,7 +122,7 @@ class YourCatchupManager(CatchupManager): # self, # content_id: str, # start_time: int, - # end_time: int, + # end_time: Optional[int] = None, # epg_id: Optional[str] = None, # **kw, # ) -> List[DRMConfig]: diff --git a/lib/streaming_providers/providers/_template/channel_manager.py b/lib/streaming_providers/providers/_template/channel_manager.py index 28a83eb..e64629d 100644 --- a/lib/streaming_providers/providers/_template/channel_manager.py +++ b/lib/streaming_providers/providers/_template/channel_manager.py @@ -3,14 +3,32 @@ {TODO: Provider name} channel manager. Subclasses base.managers.ChannelManager. See ../_template/README.md. + +Content-id grammar parsers for the whole provider live at MODULE SCOPE in +this file (the primary content-id namespace), and other managers import +them from here. One grammar, one parser. Parsers raise BadRequestError on +malformed input (the router does not catch it, so a bad id surfaces +instead of falling through to the wrong manager). Helpers shared across +managers are public -- no leading underscore. """ -from typing import Any, Dict, List, Optional +from typing import Dict, List, Optional from ...base.managers import ChannelManager -from ...base.models import Channel, DRMConfig +from ...base.models import Channel from ...base.utils.logger import logger +# from ...base.errors import BadRequestError +# from ...base.models import DRMConfig + + +# ----- Content-id grammar parsers (module scope) ----- +# +# def parse_live_id(content_id: str) -> str: +# if not content_id.startswith("live:"): +# raise BadRequestError(f"not a live id: {content_id!r}") +# return content_id[len("live:"):] + class YourChannelManager(ChannelManager): """Fetches live channels for {TODO: provider name}.""" @@ -38,6 +56,9 @@ class YourChannelManager(ChannelManager): ) # ----- Abstract methods ----- + # + # Return None / [] for "not in my domain"; raise for real failures + # (see the README's "Return-value rule"). def get_channels(self, **kw) -> List[Channel]: raise NotImplementedError("YourChannelManager.get_channels") @@ -51,5 +72,16 @@ class YourChannelManager(ChannelManager): # ----- Optional overrides ----- + # Override when the provider also has a VodManager: the default + # returns True, so without this the channel manager is tried first + # for every id. + # # def handles_content_id(self, content_id: str) -> bool: - # return content_id.isdigit() \ No newline at end of file + # return content_id.startswith(("live:", "rec:")) + + # Folded DRM architecture (DRM shares state with the manifest step): + # override this and the provider's implements_drm flips to True + # automatically. Dedicated DRM manager instead? Leave it alone. + # + # def get_channel_drm(self, content_id: str, **kw) -> List[DRMConfig]: + # return [] \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/constants.py b/lib/streaming_providers/providers/_template/constants.py index f234545..4979cf5 100644 --- a/lib/streaming_providers/providers/_template/constants.py +++ b/lib/streaming_providers/providers/_template/constants.py @@ -2,8 +2,10 @@ """ {TODO: Provider name} constants. -All URLs, endpoint paths, static header values, and default parameters -live here so no other file contains magic strings. +All URLs, endpoint paths, static header values, parameter names, and +default parameters live here so no other file contains magic strings. +That includes the provider's machine name: provider.py, auth.py and any +persistence keys read PROVIDER_NAME from here. Structure: * YourDefaults -- class-level constants. @@ -11,12 +13,18 @@ Structure: and URL builder methods. """ +from typing import Dict, Optional + class YourDefaults: + # Lowercase, no spaces; must equal the plugin directory name. PROVIDER_NAME = "TODO" PROVIDER_LOGO = "TODO: url" BASE_URL = "TODO: https://..." + # Multi-country providers: per-country overrides, keyed by the + # lowercase country code. Missing country -> BASE_URL. + BASE_URLS: Dict[str, str] = {} WEBSITE = "TODO: https://..." PATH_LOGIN = "/api/login" @@ -26,6 +34,11 @@ class YourDefaults: USER_AGENT = "TODO" TIMEOUT = 30 + # If the token travels in the URL or body instead of a header, keep + # the parameter names here (they may differ per endpoint) and let + # auth.with_token(url, param=...) pick one. Never hardcode them. + TOKEN_PARAM = "token" + # Static values the API expects (partner ids, client versions, ...). @@ -33,14 +46,21 @@ class YourConfig: """ Per-instance configuration. - Attributes the template's provider.py relies on: + Attributes the template's provider.py and auth.py rely on: user_agent -- string, passed to _setup_http_manager. timeout -- int seconds, passed to _setup_http_manager. + base_url -- resolved for the instance's country. """ - def __init__(self, config_dict: dict = None): + def __init__( + self, config_dict: Optional[dict] = None, country: Optional[str] = None + ): config = config_dict or {} - self.base_url = config.get("base_url", YourDefaults.BASE_URL) + self.country = (country or "").lower() + default_base = YourDefaults.BASE_URLS.get( + self.country, YourDefaults.BASE_URL + ) + self.base_url = config.get("base_url", default_base) self.user_agent = config.get("user_agent", YourDefaults.USER_AGENT) self.timeout = config.get("timeout", YourDefaults.TIMEOUT) # ... any other provider-specific config fields @@ -48,6 +68,7 @@ class YourConfig: # ----- Header builders ----- def get_base_headers(self) -> dict: + """Static, non-auth headers. Auth.build_headers() starts from this.""" return { "User-Agent": self.user_agent, "Accept": "application/json", diff --git a/lib/streaming_providers/providers/_template/drm_manager.py b/lib/streaming_providers/providers/_template/drm_manager.py index 10de1b7..076ca8f 100644 --- a/lib/streaming_providers/providers/_template/drm_manager.py +++ b/lib/streaming_providers/providers/_template/drm_manager.py @@ -12,6 +12,14 @@ 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. +Method names (not interchangeable) +---------------------------------- + get_drm_configs -- the dedicated DrmManager's only method + get_channel_drm -- folded architecture, live-channel entry point + get_vod_drm -- folded architecture, VOD entry point +A dedicated manager implements get_drm_configs and leaves the other two +alone; a folded provider does the opposite. + Source patterns vs. architecture -------------------------------- "Source pattern" describes HOW a provider obtains DRM material (an @@ -38,28 +46,9 @@ license URL is produced, and adapt the mechanics. 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. - ... +1. Class shape -- see YourDrmManager below. If content_type is None, + infer it from the content_id grammar; when in doubt, widen (a wrong + narrowing yields a silent [] for protected content). 2. Wiring In provider.py's __init__: @@ -112,6 +101,7 @@ How to structure a DRM manager for a new provider Pattern C -- DRM arrives with the playback response Files: providers/discovery/playback_manager.py providers/discovery/constants.py + providers/simplitv/ (folded into the channel manager) Summary: during get_manifest, POST playbackInfo and cache the response. get_drm() is a cache lookup; no separate DRM call. @@ -164,4 +154,36 @@ How to structure a DRM manager for a new provider * 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 +""" + +from typing import List, Optional + +from ...base.models import DRMConfig +from ...base.utils.logger import logger + + +class YourDrmManager: + """Dedicated DRM manager. Matches DrmManagerProtocol by shape.""" + + 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, ... + ): + self.http_manager = http_manager + self.auth = auth + self.country = country + self.config = config + + def get_drm_configs( + self, + content_id: str, + content_type: Optional[str] = None, + **opts, + ) -> List[DRMConfig]: + raise NotImplementedError("YourDrmManager.get_drm_configs") \ 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 index 75f7990..bcaf671 100644 --- a/lib/streaming_providers/providers/_template/epg_manager.py +++ b/lib/streaming_providers/providers/_template/epg_manager.py @@ -3,6 +3,16 @@ {TODO: Provider name} EPG manager. Subclasses base.managers.EpgManager. See ../_template/README.md. + +EPG does not participate in the provider's content-id router; get_epg +goes straight to this manager. EpgManager has handles_channel_id(), +used internally by get_epg_grid(). + +TIME HANDLING (TODO: state it for your provider): start_time/end_time are +datetimes. Decide and document whether you require tz-aware UTC values +and convert provider-local times at the boundary. Mixing naive and aware +datetimes (or local time zones) is the classic source of off-by-N-hours +guide bugs. Catchup, by contrast, takes integer epoch seconds. """ from datetime import datetime diff --git a/lib/streaming_providers/providers/_template/favorites_manager.py b/lib/streaming_providers/providers/_template/favorites_manager.py new file mode 100644 index 0000000..9cf0575 --- /dev/null +++ b/lib/streaming_providers/providers/_template/favorites_manager.py @@ -0,0 +1,85 @@ +# streaming_providers/providers/_template/favorites_manager.py +""" +{TODO: Provider name} favorites manager (optional). + +Include this file only if the provider supports user favorites on +programs / channels. Providers without favorites don't create a +favorites manager -- the provider's implements_favorites is False and +calls to get_favorites return []. + +See ../_template/README.md ("Favorites and bookmarks") for the contract. + +Contract summary +---------------- +* User-scoped; operates on whatever content_id the caller provides, so + it does not participate in the content_id router. +* Each returned Favorite carries a FavoriteType (program / channel / + clip / live / event). Providers that support only some types validate + the incoming type in add_favorite and reject the others + (BadRequestError). +* Removing a non-existent favorite raises KeyError. +* Backend failures raise a ProviderError subclass from base.errors. + +VERIFY the imports and signatures below against +base/provider_mixins/favorites.py (ProviderFavoritesMixin) and the +FavoritesManager ABC when you copy this file -- this scaffold mirrors the +bookmarks template and the README, not the ABC source. +""" + +from typing import List, Optional + +from ...base.managers import FavoritesManager +from ...base.models.favorite import Favorite, FavoriteType # adjust path +from ...base.utils.logger import logger + + +class YourFavoritesManager(FavoritesManager): + """Favorites for {TODO: provider name}.""" + + def __init__( + self, + *, + http_manager, + auth, + country, + config, + favorites_cache=None, + ): + super().__init__( + http_manager=http_manager, + auth=auth, + country=country, + config=config, + ) + self._favorites_cache = ( + favorites_cache if favorites_cache is not None else {} + ) + + # ----- Abstract methods ----- + + def get_favorites(self, **kw) -> List[Favorite]: + """Return all favorites for the user. [] when there are none.""" + raise NotImplementedError("YourFavoritesManager.get_favorites") + + def add_favorite( + self, + content_id: str, + favorite_type: Optional[FavoriteType] = None, + **kw, + ) -> Favorite: + """ + Add a favorite. + + Reject unsupported FavoriteType values with BadRequestError. + """ + raise NotImplementedError("YourFavoritesManager.add_favorite") + + def remove_favorite(self, content_id: str, **kw) -> None: + """ + Remove a favorite. + + Raises: + KeyError: if no favorite exists for content_id. + ProviderError: (a subclass) on backend failure. + """ + raise NotImplementedError("YourFavoritesManager.remove_favorite") \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/models.py b/lib/streaming_providers/providers/_template/models.py index 51304cc..641100c 100644 --- a/lib/streaming_providers/providers/_template/models.py +++ b/lib/streaming_providers/providers/_template/models.py @@ -2,137 +2,134 @@ """ {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). +What is needed: + * A custom AuthToken subclass -- MANDATORY for any provider with auth. + BaseAuthToken is an ABC with an abstract to_dict(), so it cannot be + instantiated directly. The minimal subclass below is live code, not + an example; auth.py imports it. + * A custom Channel subclass -- optional (extra per-channel fields; see + MoveTV's MoveTVChannel, Discovery's DiscoveryChannel, simpliTV's + SimpliTVChannel). + * A custom Credentials subclass -- optional (unusual login 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. +A provider WITHOUT auth can delete the AuthToken subclass. A provider that +uses plain Channel and UserPasswordCredentials needs nothing else here. Rules ----- * When overriding to_dict(), call super().to_dict() and add your fields. - Both Channel.to_dict() and BaseAuthToken.to_dict() chain correctly. + Channel.to_dict() chains correctly. BaseAuthToken.to_dict() is abstract, + so an AuthToken subclass implements it in full. +* to_dict() keys on Channel subclasses are TitleCase, no underscores + ("YourField"), matching the base serializer. * Custom Channel subclasses are returned from ChannelManager.get_channels() - as-is; nothing in the base inspects the concrete type. + as-is; nothing in the base inspects the concrete type. Use the inherited + factories (create_live_channel / create_vod_channel / create_radio_channel); + they use cls(...) and therefore return your subclass. * 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). """ +from dataclasses import dataclass +from typing import Any, Dict + +from ...base.auth.base_auth import BaseAuthToken + +# from ...base.models import Channel +# from ...base.auth.credentials import UserPasswordCredentials + + +# --------------------------------------------------------------------------- +# AuthToken subclass (mandatory when the provider has auth) +# --------------------------------------------------------------------------- + +@dataclass +class YourAuthToken(BaseAuthToken): + """ + Minimal concrete token. + + Add provider-specific claims as new fields WITH DEFAULTS, after the + base fields, and include them in to_dict()/from_dict(). + + to_dict() must exist even if you never persist tokens (the ABC + requires it). Implement it for real so enabling persistence later + needs no follow-up edit. + + VERIFY against base/auth/base_auth.py: the field list below mirrors + the README example. If BaseAuthToken has required fields not listed + here, add them to to_dict() and from_dict(). + """ + + def to_dict(self) -> Dict[str, Any]: + return { + "access_token": self.access_token, + "token_type": self.token_type, + "expires_in": self.expires_in, + "issued_at": self.issued_at, + "refresh_token": self.refresh_token, + "refresh_expires_in": self.refresh_expires_in, + "auth_level": self.auth_level.value, + "credential_type": self.credential_type, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "YourAuthToken": + """ + Reconstruct from a persisted dict. Used by Auth._load_session(). + + Mirror to_dict(). auth_level is serialized via `.value`, so it + must be converted back to its enum here (see base_auth.py); + until you do, keep persistence off or let _load_session() return + None -- a failed load only costs one re-authentication. + """ + 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), + refresh_token=data.get("refresh_token"), + refresh_expires_in=data.get("refresh_expires_in", 0), + # TODO: auth_level=..., credential_type=... + ) + + # --------------------------------------------------------------------------- # 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. +# them so positional construction still works. Never remove or rename +# base fields -- downstream consumers read them. # """ # -# # Provider-specific extras. -# your_field: str = "" -# your_expires_at: float = 0.0 +# codename: str = "" +# recording_id: str = "" # # def to_dict(self) -> Dict[str, Any]: # result = super().to_dict() -# result["YourField"] = self.your_field -# result["YourExpiresAt"] = self.your_expires_at +# result["Codename"] = self.codename +# result["RecordingId"] = self.recording_id # 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 +# Only needed when the login payload isn't the usual +# {username, password} (HRTi's grant_access takes # {Username, Password, OperatorReferenceId}, for example). # """ # diff --git a/lib/streaming_providers/providers/_template/provider.py b/lib/streaming_providers/providers/_template/provider.py index 578975e..24e1929 100644 --- a/lib/streaming_providers/providers/_template/provider.py +++ b/lib/streaming_providers/providers/_template/provider.py @@ -8,11 +8,19 @@ the public StreamingProvider interface. Subclasses the existing StreamingProvider. Does NOT subclass any new base class. Authentication is lazy -- no network I/O in __init__. -All seven managers are optional. This template shows the shape for a -provider that has channels, VOD, and EPG. Delete the factories for -capabilities you don't have, or return None from them. +There are seven optional manager ABCs (channels, vod, epg, recordings, +favorites, bookmarks, catchup) plus an optional DRM manager (a protocol, +not an ABC). This template shows the shape for a provider that has +channels, VOD, and EPG. Delete the factories for capabilities you don't +have, or return None from them. + +Plugin name: the registry derives it from the CLASS NAME via +`cls.__name__.lower().replace("provider", "")`. `YourProvider` becomes +"your". Name the class so that this matches your plugin directory, e.g. +`SimpliTVProvider` -> "simplitv" -> providers/simplitv/. """ +from datetime import datetime from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple from ...base.errors import NotFoundError @@ -21,10 +29,12 @@ from ...base.models.proxy_models import ProxyConfig from ...base.protocols import DrmManagerProtocol from ...base.provider import StreamingProvider from ...base.utils.logger import logger +from ...base.vod import VodPage from .auth import YourProviderAuth from .channel_manager import YourChannelManager -from .constants import YourConfig +from .constants import YourConfig, YourDefaults +# from .channel_manager import parse_catchup_id # if you route catchup # from .vod_manager import YourVodManager # from .epg_manager import YourEpgManager # from .recordings_manager import YourRecordingsManager @@ -55,20 +65,20 @@ class YourProvider(StreamingProvider): # The value is the machine identifier: lowercase, no spaces, matching # the plugin directory name and the PROVIDER_NAME constant in # constants.py. Used in settings keys, log lines, and the `provider` - # field on models. + # field on models. Return the constant so the two cannot drift. # # Do not delete this property. Override the return value; do not # replace it with a class attribute. @property def provider_name(self) -> str: - return "TODO: provider_name" + return YourDefaults.PROVIDER_NAME # ------------------------------------------------------------------ - # Class metadata + # Class metadata (read by the registry BEFORE any instance exists) # ------------------------------------------------------------------ PROVIDER_LABEL: ClassVar[str] = "TODO: display label" - PROVIDER_LOGO: ClassVar[str] = "TODO: logo url" + PROVIDER_LOGO: ClassVar[str] = YourDefaults.PROVIDER_LOGO SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["user_credentials"] # ALWAYS set SUPPORTED_COUNTRIES. Never leave it at the base @@ -78,7 +88,8 @@ class YourProvider(StreamingProvider): # # Single country: ["AT"] # Multi-country: ["hr", "pl", "me", "at", "hu"] - # Wildcard: ["*"] (country discovered at runtime) + # Wildcard: ["*"] (country discovered at runtime; + # also the right answer for "not sure yet") # # See the README's "SUPPORTED_COUNTRIES is not optional" section. SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["TODO"] @@ -100,8 +111,16 @@ class YourProvider(StreamingProvider): ): super().__init__(country) + # Unknown kwargs are tolerated (the registry may pass host-level + # extras) but never silently: a typo here is otherwise invisible. + if kwargs: + logger.debug( + f"{self.provider_name}: ignoring unknown kwargs " + f"{sorted(kwargs)}" + ) + config = config or {} - self.config = YourConfig(config) + self.config = YourConfig(config, country=self.country) # 1. HTTP manager. self.http_manager = self._setup_http_manager( @@ -113,10 +132,10 @@ class YourProvider(StreamingProvider): # 2. Auth (lazy -- no network call in __init__). # - # Providers WITHOUT auth: leave self.auth = None, and either - # accept the manager base constructors' AuthProtocol warning, - # or provide a minimal stub with the three methods. See the - # README's "Providers without auth" section. + # Providers WITHOUT auth: _build_auth returns None (accept the + # manager base constructors' AuthProtocol warning) or a minimal + # stub with the three token methods. See the README's + # "Providers without auth" section. self._credentials = credentials self.auth = self._build_auth(settings_manager) @@ -125,16 +144,17 @@ class YourProvider(StreamingProvider): self._channels_cache: Dict = {} self._playback_cache: Dict = {} - # 4. Managers. Every factory returns a manager or None. - # Delete the lines for capabilities you don't have, or leave - # the corresponding _build_* returning None. + # 4. Managers, in DEPENDENCY ORDER. Every factory returns a + # manager or None. Catchup (and a dedicated DRM manager) may + # need channels/epg/vod, so they are built after them. Do not + # introduce cycles between managers. self.channels = self._build_channels() self.vod = self._build_vod() self.epg = self._build_epg() self.recordings = self._build_recordings() self.favorites = self._build_favorites() self.bookmarks = self._build_bookmarks() - self.catchup = self._build_catchup() + self.catchup = self._build_catchup() # sees channels + epg self.drm = self._build_drm() # ------------------------------------------------------------------ @@ -146,13 +166,16 @@ class YourProvider(StreamingProvider): Return the provider's Auth instance, or None if the provider needs no authentication. - See the README's "Providers without auth" section for the - minimal stub shape. + `credentials=` MUST be forwarded: it is credentials source #1 + (constructor argument). The auth class falls back to the + settings_manager and CredentialManager on its own. """ return YourProviderAuth( http_manager=self.http_manager, country=self.country, settings_manager=settings_manager, + credentials=self._credentials, + config=self.config, ) def _build_channels(self) -> Optional[ChannelManager]: @@ -210,10 +233,18 @@ class YourProvider(StreamingProvider): """ Return a CatchupManager, or None. - Catchup usually needs the channel manager as a collaborator - (to reuse the live manifest fetch). Ensure - self.channels is built before this factory runs. + Catchup usually needs the channel manager and/or the EPG manager + as collaborators. Pass them as explicit keyword-only arguments; + self.channels and self.epg are already built when this runs. """ + # TODO: return YourCatchupManager( + # http_manager=self.http_manager, + # auth=self.auth, + # country=self.country, + # config=self.config, + # channels=self.channels, + # epg=self.epg, + # ) return None def _build_drm(self) -> Optional[DrmManagerProtocol]: @@ -231,10 +262,9 @@ class YourProvider(StreamingProvider): get_vod_drm() on your VodManager. The provider's get_drm() falls back to routing to those. - New providers should prefer the dedicated manager unless the - DRM step shares state with the manifest step. See - providers/_template/drm_manager.py for the four existing - source patterns. + Rule: fold if DRM shares state with the manifest step; otherwise + use the dedicated manager. See providers/_template/drm_manager.py + for the four existing source patterns. """ return None @@ -315,6 +345,13 @@ class YourProvider(StreamingProvider): 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). + + Any OTHER exception propagates. In particular BadRequestError is + deliberately NOT caught: if a manager uses a 400 as an endpoint + dispatch signal, override handles_content_id() on that manager + instead of relying on try-and-catch. When both channels and vod + exist, override handles_content_id() on both -- the default + (True) makes the first manager see every id. """ last_not_found: Optional[NotFoundError] = None for manager, call in attempts: @@ -333,6 +370,13 @@ class YourProvider(StreamingProvider): # ------------------------------------------------------------------ # Public delegations + # + # The optional capabilities (recordings, favorites, bookmarks, + # catchup) are exposed by the base layer's Provider*Mixin classes, + # which correspond one-to-one to those managers -- no delegation is + # needed here. Channels, VOD and EPG are delegated explicitly below. + # VERIFY these three signatures against StreamingProvider when you + # copy the template; remove any the base class already provides. # ------------------------------------------------------------------ def get_channels(self, **kw): @@ -340,15 +384,50 @@ class YourProvider(StreamingProvider): return [] return self.channels.get_channels(**kw) + def get_vod_category( + self, + content_id: str = "", + cursor: Optional[str] = None, + page_size: int = 24, + **kw, + ) -> VodPage: + if self.vod is None: + return VodPage() + return self.vod.get_vod_category( + content_id, cursor=cursor, page_size=page_size, **kw + ) + + def get_epg( + self, + channel_id: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + **kw, + ): + if self.epg is None: + return [] + return self.epg.get_epg( + channel_id, start_time=start_time, end_time=end_time, **kw + ) + def get_manifest(self, content_id: str, **kw) -> Optional[str]: """ Return the manifest URL for the given content, routing by manager. - Providers with catchup, events, or other content types extend - this method with additional branches. Providers with a - structured content_id grammar add explicit prefix branches - above _route when the manager needs parsed arguments -- see the - README's "Parsers vs. dispatch". + Route through `_route` when the manager needs only the + content_id. Add an explicit prefix branch ABOVE `_route` when the + manager needs parsed arguments (a timestamp, an episode index). + One parser, in the router -- see the README's "Parsers vs. + dispatch". Catchup is the usual example: + + # if content_id.startswith("catchup:"): + # parsed = parse_catchup_id(content_id) # raises + # return self.catchup.get_catchup_manifest( # BadRequestError + # parsed.content_id, parsed.start_time, parsed.end_time, + # **kw, + # ) if self.catchup else None + + Do NOT fall back to the live manifest when catchup fails. """ return self._route(content_id, [ (self.channels, lambda m: m.get_channel_manifest( diff --git a/lib/streaming_providers/providers/_template/recordings_manager.py b/lib/streaming_providers/providers/_template/recordings_manager.py index cd9d979..8de5632 100644 --- a/lib/streaming_providers/providers/_template/recordings_manager.py +++ b/lib/streaming_providers/providers/_template/recordings_manager.py @@ -12,7 +12,7 @@ See ../_template/README.md for the contract. Reference implementation ------------------------ simpliTV's SimpliTVRecordingsManager -(providers/simpli/recordings_manager.py) is the first example: it +(providers/simplitv/recordings_manager.py) is the first example: it returns recordings as SimpliTVChannel objects, carries recording_id on the subclass, and paginates through /v2/Pvr/GetRecordings. @@ -22,9 +22,13 @@ A recording has its own id (recording_id) distinct from the content_id of the underlying programme. The two namespaces are usually different: recording_id is what you pass to delete_recording, content_id is what you pass to get_manifest to play the recording. Keep them separate. + +Recordings do not participate in the content_id router (they have their +own id namespace); the manager may override handles_recording_id() when +several recordings managers exist (cloud PVR + local PVR, say). """ -from typing import Any, List +from typing import List from ...base.managers import RecordingsManager from ...base.models import Channel @@ -51,7 +55,9 @@ class YourRecordingsManager(RecordingsManager): Do not add a get_manifest method here. Route it in the provider's get_manifest instead. See - providers/simpli/provider.py for the pattern. + providers/simplitv/provider.py for the pattern. (Two managers + may accept the same prefix, e.g. "rec:", when their concerns + are disjoint -- but only ONE router branch per prefix.) Recording identity ------------------ @@ -97,8 +103,9 @@ class YourRecordingsManager(RecordingsManager): Delete a recording. Raises: - KeyError: if the recording doesn't exist. - ProviderError: on backend failure. + KeyError: if the recording doesn't exist. + ProviderError: (a subclass from base.errors) on backend + failure. Never return silently on failure. """ raise NotImplementedError("YourRecordingsManager.delete_recording") diff --git a/lib/streaming_providers/providers/_template/vod_manager.py b/lib/streaming_providers/providers/_template/vod_manager.py index c4ee592..e213c42 100644 --- a/lib/streaming_providers/providers/_template/vod_manager.py +++ b/lib/streaming_providers/providers/_template/vod_manager.py @@ -5,15 +5,20 @@ Subclasses base.managers.VodManager. Document the content_id grammar here. See ../_template/README.md for the contract. + +Every navigation method returns VodPage. To paginate use `page.has_more` +(NOT bool(page)); `next_cursor is None` is the authoritative end-of-list +signal and `total` is informational only. """ -from typing import Any, Dict, Optional +from typing import Dict, List, Optional from ...base.managers import VodManager -from ...base.models import DRMConfig from ...base.vod import VodPage from ...base.utils.logger import logger +# from ...base.models import DRMConfig + class YourVodManager(VodManager): """ @@ -63,5 +68,13 @@ class YourVodManager(VodManager): # ----- Optional overrides ----- + # Override when the provider also has a ChannelManager (see the + # channel manager template for why). + # # def handles_content_id(self, content_id: str) -> bool: - # return content_id.startswith(("details_", "clip_")) \ No newline at end of file + # return content_id.startswith(("details_", "clip_")) + + # Folded DRM architecture; see the channel manager template. + # + # def get_vod_drm(self, content_id: str, **kw) -> List[DRMConfig]: + # return [] \ No newline at end of file