diff --git a/lib/streaming_providers/providers/_template/README.md b/lib/streaming_providers/providers/_template/README.md index 0c9890e..c09c447 100644 --- a/lib/streaming_providers/providers/_template/README.md +++ b/lib/streaming_providers/providers/_template/README.md @@ -6,52 +6,119 @@ 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). +- Credential storage / Kodi sync via the base `settings_manager` (if the + provider needs credentials). +- Token caching and session persistence (in your Auth class, if you have one). - Capability flags (`implements_vod`, `implements_epg`, `implements_recordings`, ...) — 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** — one file per capability the provider supports. Each +1. **Provider** — `provider.py`. Required. Declares the provider's class + metadata, wires up whatever managers it has, and exposes the public + interface. +2. **Auth** — `auth.py`. Optional. Required only if the provider + authenticates requests. Free / static-key providers can omit it + entirely, or provide a minimal `Auth` that only sets base headers. +3. **Managers** — one file per capability the provider supports. Each subclasses the corresponding ABC from `base/managers/` and implements - the abstract methods. See "The manager ABCs" below for the full list. -3. **Provider wiring** — `provider.py`. Fills in the `_build_*` factory - methods; returns `None` for capabilities the provider doesn't have. + the abstract methods. **All seven managers are optional** — a + VOD-only provider has no `ChannelManager`; a linear-only provider has + no `VodManager`; a favorites-sync-only provider may have neither. + See "The manager ABCs" below. 4. **Constants** — `constants.py`. URLs, endpoints, static headers. 5. **Models** (optional) — `models.py`. Only if you need a custom Channel or AuthToken subclass. +## Provider class metadata + +Every provider declares these class attributes. They are used by the +registry and the UI before any instance is constructed. + + PROVIDER_LABEL: ClassVar[str] # display name, e.g. "simpliTV" + PROVIDER_LOGO: ClassVar[str] # logo URL + SUPPORTED_AUTH_TYPES: ClassVar[List[str]] # e.g. ["user_credentials"] + SUPPORTED_COUNTRIES: ClassVar[List[str]] # ALWAYS set this + +### SUPPORTED_COUNTRIES is not optional + +**Always declare `SUPPORTED_COUNTRIES`.** Never leave it at the base +default. The base class defines it as an empty list, which has a +specific meaning: "the provider does not support country-specific +instances." That meaning is easy to collide with the accidental case of +"the author forgot to declare it," and the failure mode is silent — the +provider shows up in every country's list or in none, depending on +which code path reads it. + +Declare it explicitly, even for a single-country provider: + + # Single country + SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["AT"] + + # Multi-country with per-country instances + SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["hr", "pl", "me", "at", "hu"] + + # Multi-country with per-country instances but no explicit list — + # the provider discovers its country at runtime (Discovery+ shape) + SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["*"] + +The three cases: + +* **Single country** — one-element list. The registry creates one + instance for that country. +* **Multi-country** — one instance per listed country. `country` is a + constructor argument; each instance is independent. +* **Wildcard** — `["*"]`. The registry creates one instance for the + default country; the provider discovers its actual country at + runtime (e.g. from a `/users/me` call). + +An empty list is reserved for providers with no country concept at all +(a purely global, country-agnostic service). If you find yourself +wanting to leave it empty because "I'm not sure yet," declare `["*"]` +instead — it's honest about the ambiguity and behaves correctly in +both the registry and the runtime. + ## The manager ABCs -There are **seven** manager ABCs. Three are required capabilities, four -are optional. Providers implement the ones their service offers and -return `None` from the corresponding `_build_*()` for the rest. - -### Required capabilities +There are **seven** manager ABCs. **All seven are optional.** A provider +implements the ones its service offers and returns `None` from the +corresponding `_build_*()` for the rest. ChannelManager -- live channels, channel manifest, channel DRM - VodManager -- browseable VOD catalogue (None for live-only) - EpgManager -- EPG (None for providers without EPG) - -### Optional capabilities - + VodManager -- browseable VOD catalogue + EpgManager -- EPG RecordingsManager -- cloud / network PVR FavoritesManager -- user favorites on programs / channels BookmarksManager -- resume positions CatchupManager -- timeshift / restart **The rule: if a capability area has a public interface in the base -layer, it gets a manager ABC.** The base layer's +layer, it gets a manager ABC. If the provider has the capability, wire +the manager; if not, return `None` from the factory.** The base layer's `ProviderRecordingsMixin`, `ProviderFavoritesMixin`, `ProviderBookmarksMixin`, and `ProviderCatchupMixin` correspond one-to-one to the four optional managers. Do not fold a capability into another manager because the data happens to come from the same endpoint — the public interface is the contract, not the URL. +### Providers vary in which managers they have + +Common shapes: + + Linear-only free provider -> ChannelManager + EpgManager + Linear + catchup -> ChannelManager + EpgManager + CatchupManager + VOD-only -> VodManager + VOD + linear -> ChannelManager + VodManager + Linear + recordings -> ChannelManager + RecordingsManager (+ EpgManager) + Metadata-only (EPG feed) -> EpgManager only + Favorites-sync-only -> FavoritesManager only + +Do not assume "every provider has channels." Do not assume "every +provider has VOD." Do not assume "every provider has EPG." If a +capability is missing, `_build_*()` returns `None`, the capability flag +is `False`, and callers that gate on the flag skip it cleanly. + ### Constructor contract All seven managers share the same constructor contract: @@ -380,7 +447,55 @@ helper should either be renamed public or moved to a shared module ## The Auth protocol Auth is a documented protocol (see `base/protocols.py`), not an ABC. -Every provider writes: +**Auth is optional** — a free provider that never authenticates requests +does not need one at all. + +### Providers without auth + +Some providers need no authentication: + +* Free, public-content providers with no user accounts. +* Static-API-key providers where the key never changes and is best + expressed in `constants.py` headers. +* Providers where every request is anonymous and no session state is + carried. + +For these, `_build_auth()` returns `None`, `self.auth = None`, and any +manager that receives `auth=None` must not call its methods. This is +supportable but produces a warning from the manager base constructors +(the `AuthProtocol` isinstance check fires). If you are writing a +provider with no auth: + +* Return `None` from `_build_auth()`. +* Either accept the warning (it is non-fatal — the manager still + constructs and runs), or +* Provide a minimal `Auth` stub that only implements `build_headers()` + and returns the static headers. This is usually cheaper than + suppressing the warning, because managers that call `auth.build_headers()` + still work. + +Minimal no-auth stub: + + class YourNoAuth: + """Auth stub for a provider with no authentication.""" + + def get_access_token(self, force_refresh=False): + return "" + + def build_headers(self, token=None, **opts): + return {"User-Agent": "...", "Accept": "application/json"} + + def invalidate(self): + pass + +Wire it as `self.auth = YourNoAuth()` in `_build_auth()`. The manager +ABCs' isinstance check passes (all three required methods are present), +and `build_headers()` returns whatever static headers the provider +needs. + +### Providers with auth + +Every provider with auth writes: get_access_token(force_refresh=False) -> str Raw token string. No scheme prefix. @@ -422,7 +537,10 @@ assume a bearer token is being sent. 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. +signatures — a mismatched signature will not be caught here. A warning +from this check means the `auth` object is missing one of the three +required methods; it is not fatal, but it usually means a wiring +mistake. ## DRM @@ -661,6 +779,9 @@ 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. +For providers with no auth, this section does not apply — there is +nothing to authenticate. + ## Reference providers The six existing providers, in order of implementation complexity: diff --git a/lib/streaming_providers/providers/_template/provider.py b/lib/streaming_providers/providers/_template/provider.py index eb38474..939ec2b 100644 --- a/lib/streaming_providers/providers/_template/provider.py +++ b/lib/streaming_providers/providers/_template/provider.py @@ -7,6 +7,10 @@ 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. """ from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple @@ -23,6 +27,10 @@ from .channel_manager import YourChannelManager from .constants import YourConfig # from .vod_manager import YourVodManager # from .epg_manager import YourEpgManager +# from .recordings_manager import YourRecordingsManager +# from .favorites_manager import YourFavoritesManager +# from .bookmarks_manager import YourBookmarksManager +# from .catchup_manager import YourCatchupManager # from .drm_manager import YourDrmManager @@ -32,18 +40,22 @@ class YourProvider(StreamingProvider): 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"] - # Module-private constants used by get_drm's folded path. + # ALWAYS set SUPPORTED_COUNTRIES. Never leave it at the base + # default (an empty list), which has a specific meaning: "no + # country concept at all." Even a single-country provider declares + # a one-element list. # - # Only these two values narrow the search when content_type is - # provided. Anything else (None, "event", "catchup", or a typo) - # tries both domains. This is deliberate: a wrong narrowing produces - # a silent [] for protected content, which is the hardest kind of - # bug to trace. Widening on unknown input is always safe. + # Single country: ["AT"] + # Multi-country: ["hr", "pl", "me", "at", "hu"] + # Wildcard: ["*"] (country discovered at runtime) # - # Adding more narrowing values here is a deliberate act and should - # be justified by a caller that reliably knows the content type. + # See the README's "SUPPORTED_COUNTRIES is not optional" section. + SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["TODO"] + + # Only "live" and "vod" narrow the folded DRM search; anything else + # (None, "event", "catchup", a typo) tries both domains. See the + # README's "content_type hint semantics". _LIVE_ONLY_CONTENT_TYPES = frozenset({"live"}) _VOD_ONLY_CONTENT_TYPES = frozenset({"vod"}) @@ -53,6 +65,7 @@ class YourProvider(StreamingProvider): config: Optional[Dict] = None, proxy_config: Optional[ProxyConfig] = None, settings_manager=None, + credentials=None, **kwargs, ): super().__init__(country) @@ -60,7 +73,7 @@ class YourProvider(StreamingProvider): config = config or {} self.config = YourConfig(config) - # 1. HTTP manager (existing StreamingProvider helper). + # 1. HTTP manager. self.http_manager = self._setup_http_manager( provider_name="TODO: provider_name", proxy_config=proxy_config, @@ -68,17 +81,30 @@ class YourProvider(StreamingProvider): timeout=self.config.timeout, ) - # 2. Auth (protocol, not ABC). Lazy -- no network call here. + # 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. + self._credentials = credentials self.auth = self._build_auth(settings_manager) # 3. Provider-owned caches. Managers borrow these by reference. + # Add caches here as the provider needs them. self._channels_cache: Dict = {} self._playback_cache: Dict = {} - # 4. Managers. + # 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. 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.drm = self._build_drm() # ------------------------------------------------------------------ @@ -86,13 +112,27 @@ class YourProvider(StreamingProvider): # ------------------------------------------------------------------ def _build_auth(self, settings_manager): + """ + 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. + """ return YourProviderAuth( http_manager=self.http_manager, country=self.country, settings_manager=settings_manager, ) - def _build_channels(self): + def _build_channels(self) -> Optional[ChannelManager]: + """ + Return a ChannelManager, or None if the provider has no live + channels. + + A VOD-only provider returns None here. A free linear-only + provider returns a manager here and None from _build_vod. + """ return YourChannelManager( http_manager=self.http_manager, auth=self.auth, @@ -101,7 +141,11 @@ class YourProvider(StreamingProvider): channels_cache=self._channels_cache, ) - def _build_vod(self): + def _build_vod(self) -> Optional[VodManager]: + """ + Return a VodManager, or None if the provider has no browseable + VOD catalogue. + """ # TODO: return YourVodManager( # http_manager=self.http_manager, # auth=self.auth, @@ -112,35 +156,56 @@ class YourProvider(StreamingProvider): return None def _build_epg(self): + """ + Return an EpgManager, or None if the provider has no EPG. + + Some providers have channels but no EPG; some have EPG but no + channels. The two capabilities are independent. + """ + return None + + def _build_recordings(self): + """Return a RecordingsManager, or None.""" + return None + + def _build_favorites(self): + """Return a FavoritesManager, or None.""" + return None + + def _build_bookmarks(self): + """Return a BookmarksManager, or None.""" + return None + + def _build_catchup(self): + """ + 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. + """ return None def _build_drm(self) -> Optional[DrmManagerProtocol]: """ Return a dedicated DRM manager, or None. - Two supported architectures (see the README's "DRM" section for - the state-sharing rule that picks between them): + Two supported architectures (see the README's "DRM" section): - * Dedicated manager: return a class matching DrmManagerProtocol - here; the provider's get_drm() delegates to it. + * 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 + * Folded into managers: leave this returning None, and 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 unless the DRM - step shares state with the manifest step. See - providers/_template/drm_manager.py for the four existing source - patterns. + 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. """ - # TODO: return YourDrmManager( - # http_manager=self.http_manager, - # auth=self.auth, - # country=self.country, - # config=self.config, - # # ... provider-specific collaborators - # ) return None # ------------------------------------------------------------------ @@ -159,6 +224,22 @@ class YourProvider(StreamingProvider): def implements_epg(self) -> bool: return self.epg is not None + @property + def implements_recordings(self) -> bool: + return self.recordings is not None + + @property + def implements_favorites(self) -> bool: + return self.favorites is not None + + @property + def implements_bookmarks(self) -> bool: + return self.bookmarks is not None + + @property + def implements_catchup(self) -> bool: + return self.catchup is not None + @property def implements_drm(self) -> bool: """ @@ -170,8 +251,7 @@ class YourProvider(StreamingProvider): * 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. + comparing the bound method against the base class's method. """ if self.drm is not None: return True @@ -205,11 +285,6 @@ 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). - - BadRequestError and other errors are NOT caught here. Providers - that need endpoint-fallback behavior (Magenta's page-vs-component - dispatch) should override handles_content_id() instead, so the - router never has to guess. """ last_not_found: Optional[NotFoundError] = None for manager, call in attempts: @@ -239,13 +314,17 @@ class YourProvider(StreamingProvider): """ Return the manifest URL for the given content, routing by manager. - Providers with events, catchup, or other content types extend - this method to add their branches. Each branch is a - (manager, call) tuple in the attempts list. + 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". """ 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)), + (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( @@ -266,21 +345,18 @@ class YourProvider(StreamingProvider): If a dedicated DRM manager is configured, the hint is passed through unchanged and the manager decides what to do with it. - Otherwise the folded path narrows the search as described above. """ if self.drm is not None: return self.drm.get_drm_configs( - content_id, - content_type=content_type, - **kw, + content_id, content_type=content_type, **kw ) - # Folded path: invert the check so only recognized values narrow. - # Unknown values (including typos) fall through to "try both". attempts: List[Tuple[Any, Callable]] = [] if content_type not in self._VOD_ONLY_CONTENT_TYPES: attempts.append( - (self.channels, lambda m: m.get_channel_drm(content_id, **kw)) + (self.channels, lambda m: m.get_channel_drm( + content_id, **kw + )) ) if content_type not in self._LIVE_ONLY_CONTENT_TYPES: attempts.append(