Template README - additions

This commit is contained in:
Nirvana
2026-10-04 07:37:28 +02:00
parent 307ebe6e0b
commit 1d3288b0af
2 changed files with 267 additions and 70 deletions
@@ -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:
@@ -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(