Files
script.service.ultimate/lib/streaming_providers/providers/_template/README.md
T
2026-10-04 16:41:49 +02:00

1274 lines
53 KiB
Markdown

# Provider template
Copy this directory to `providers/{your_provider}/`, rename the classes,
and fill in the stubs. Read this file first — it explains the contract.
## What you get for free
- HTTP manager setup, proxying, retries.
- Credential storage / Kodi sync via the base `settings_manager` (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 `Content`/`Channel`
data model.
## What you implement
1. **Provider** — `provider.py`. Required. Declares the provider's class
members, 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. **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** — often required. A custom `AuthToken` subclass is
mandatory whenever the auth class returns a `BaseAuthToken`, because
`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
the concrete class, or the class cannot be instantiated); some are class
attributes with defaults.
### Required abstract property
@property
def provider_name(self) -> str: ...
**This is abstract on StreamingProvider.** A subclass that does not
override it cannot be instantiated — Python raises
`TypeError: Can't instantiate abstract class ... with abstract method
provider_name` the moment the registry calls `YourProvider(country=...)`.
The failure is silent if you don't notice it: the registry catches the
exception, logs "Failed to create instance for {name}: ..." at ERROR
level, and moves on. The provider simply does not appear in the UI. If
your provider is registered but never instantiates, this is the first
thing to check.
The value is the provider's machine identifier — lowercase, no spaces,
used in settings keys, log lines, and the `provider` field on models.
It should match the plugin directory name and the `PROVIDER_NAME`
constant in `constants.py`.
@property
def provider_name(self) -> str:
return "your_provider_name"
### Required class attributes
PROVIDER_LABEL: ClassVar[str]
Display name, e.g. "simpliTV". Used by the registry and the UI.
PROVIDER_LOGO: ClassVar[str]
Logo URL.
SUPPORTED_AUTH_TYPES: ClassVar[List[str]]
e.g. ["user_credentials"], ["anonymous"], or ["user_credentials",
"anonymous"] if the provider supports both.
SUPPORTED_COUNTRIES: ClassVar[List[str]]
ALWAYS set this. See "SUPPORTED_COUNTRIES is not optional" below.
### 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.
### Cross-check: what the registry reads
The registry's `ProviderMetadata._extract_metadata` reads these members
before any instance exists. If any are missing or wrong, the provider
misbehaves in the UI even if the runtime works.
PROVIDER_LABEL -> metadata.label
PROVIDER_LOGO -> metadata.logo
SUPPORTED_AUTH_TYPES -> metadata.supported_auth_types
SUPPORTED_COUNTRIES -> metadata.supported_countries
class.__name__ -> metadata.plugin_name (derived)
The plugin name is derived from the class name:
`cls.__name__.lower().replace("provider", "")`. `YourProvider` becomes
`your`. If your class name is non-standard the derived name will be
wrong — pick a class name whose lowercase form (minus the word
"provider") matches your intended plugin name.
## Models
The base package provides `Content` (the base dataclass) and its
subclass `Channel`. Both are dataclasses, and providers build on them
rather than replacing them.
### `Content` — the base dataclass
`Content` holds every field that all provider content shares: name,
id, provider, manifest URLs, DRM placeholders, metadata, pricing.
`Channel` extends it with channel-specific fields. See
`base/models/content.py` and `base/models/channel.py` for the full
field list.
**Key fields and how to set them:**
- `manifest: Optional[str]` — the static manifest URL, when the
provider has one. Set directly, or via `set_static_manifest(url)`.
- `manifest_script: Optional[str]` — for dynamic manifests, provider-
specific parameters fetched at request time. Set via
`set_dynamic_manifest(params)`.
- `session_manifest: bool` — True when the manifest must be fetched
per-session. Mutually exclusive with `manifest` in practice: if
`session_manifest=True` and `manifest` is also set, `Channel`'s
`_validate_fields` logs a warning and the static URL is ignored.
- `streaming_format: Optional[str]` — `"dash"`, `"hls"`, or None.
Serialized as `"StreamingFormat"` in `to_dict()`.
**Do not set both `manifest` and `manifest_script` and expect both to
be used.** They are alternatives. If the provider fetches the manifest
per-session, use `set_dynamic_manifest` and leave `manifest` None.
**Serialization key convention.** `to_dict()` emits TitleCase keys
(`Name`, `Id`, `Provider`, `Manifest`, `StreamingFormat`, ...). A
subclass adding fields via `result["YourField"] = ...` must match this
convention — TitleCase, no underscores. Downstream consumers expect
uniform keys.
### `Channel` — the base channel dataclass
`Channel` extends `Content` with `channel_number`, `is_radio`, and
`catchup_hours`. It also provides:
**Three factory classmethods — use these, don't construct directly:**
Channel.create_live_channel(name, channel_id, provider, **kwargs)
Channel.create_vod_channel(name, content_id, provider, **kwargs)
Channel.create_radio_channel(name, channel_id, provider, **kwargs)
Each sets `mode` and `content_type` (and `is_radio`/`quality` for
radio) correctly, so the resulting object never trips the
`__post_init__` consistency warnings. The factories use `cls(...)`, so
a subclass `SimpliTVChannel.create_live_channel(...)` returns a
`SimpliTVChannel`, not a base `Channel`. Subclasses should use the
inherited factories rather than construct directly.
**Note the parameter-name inconsistency:** `create_live_channel` and
`create_radio_channel` take `channel_id`, while `create_vod_channel`
takes `content_id`. All three set the dataclass field `content_id`
internally. If you call `create_vod_channel(channel_id=...)` you get
`TypeError`. Pass positionally or use the right keyword.
**`__post_init__` mutates `content_type` and `quality` for radio.** If
`is_radio=True` and `content_type="LIVE"`, the base class rewrites the
content_type to `"RADIO"` and quality to `"AUDIO"`. This happens in the
base class, so a subclass that sets these differently must account for
it (or pass `content_type` and `quality` explicitly and skip the
`is_radio=True` path). See `channel.py`'s `__post_init__`.
**`__post_init__` also logs warnings for inconsistent fields.** A
`Channel` with `mode="vod"` and `content_type="LIVE"` gets a warning,
not an exception. Same for `session_manifest=True` combined with a
static `manifest`. These are advisory — the code runs — but they
indicate a likely provider bug. Check the logs during development; a
clean startup log with no `Channel ...:` warnings is the goal.
**`detect_and_set_radio()` is heuristic and mutates in place.** It
looks at `name`, `quality`, `description`, and `genre` for radio
indicators and sets `is_radio=True` if any match. Providers whose
channel classification is authoritative (from an API field) should set
`is_radio` at construction and not call this method. Providers whose
classification comes from names or metadata can call it after
construction.
### Subclassing `Channel`
A provider that carries extra per-channel fields subclasses `Channel`
and adds them. MoveTV, Discovery, HRTi, and simpliTV all do this.
Rules:
1. **Call `super().to_dict()` and add fields in TitleCase.** The base
serializer emits TitleCase; subclasses must match.
2. **Keep the base field names and defaults.** Add new fields at the
end with sensible defaults, so existing construction patterns
(positional or keyword) don't break.
3. **Do not remove or rename base fields.** Downstream consumers read
`channel_id` / `content_id`, `name`, `manifest`, and the other base
fields. If the provider needs a differently-named field, add it as
a new field rather than renaming.
4. **Use the inherited factory methods.** `YourChannel.create_live_channel(...)`
returns a `YourChannel` because the factories use `cls(...)`. Do not
override them unless you need to change what they set.
Example:
@dataclass
class YourChannel(Channel):
codename: str = ""
recording_id: str = ""
recording_status: str = ""
def to_dict(self) -> Dict[str, Any]:
result = super().to_dict()
result["Codename"] = self.codename
result["RecordingId"] = self.recording_id
result["RecordingStatus"] = self.recording_status
return result
### The `StreamingChannel` alias
`base/models/channel.py` ends with:
StreamingChannel = Channel
`StreamingChannel` and `Channel` are the same class. Providers and
consumers may import either name; both refer to the same type.
Do not treat them as distinct classes — `isinstance(x, StreamingChannel)`
and `isinstance(x, Channel)` are identical checks.
### `StreamingMode` and `ContentType` are not Enums
`base/models/content.py` defines them as plain classes with string
class attributes:
class StreamingMode:
LIVE = "live"
VOD = "vod"
class ContentType:
LIVE = "LIVE"
VOD = "VOD"
SERIES = "SERIES"
MOVIE = "MOVIE"
RADIO = "RADIO"
Consequences:
- Compare with `==`, not `is`. `channel.mode == StreamingMode.LIVE`
works; `channel.mode is StreamingMode.LIVE` is fragile (string
interning makes it usually work, but not guaranteed).
- `isinstance(x, StreamingMode)` never works. There is no instance of
`StreamingMode` — it's a namespace, not a type.
- Do not `import Enum` and try to `StreamingMode.LIVE.value`. The
attribute is already a string.
If you find yourself wanting stricter typing, use the string values
directly (`"live"`, `"vod"`, `"LIVE"`, ...). The classes exist for
readability and autocomplete, not type enforcement.
### `AuthToken` subclasses
`BaseAuthToken` is an ABC with an abstract `to_dict()`, so it cannot be
instantiated directly. Every provider whose `_perform_authentication()`
returns a `BaseAuthToken` needs a concrete subclass — usually tiny:
@dataclass
class YourAuthToken(BaseAuthToken):
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,
}
If the provider doesn't persist tokens, `to_dict()` is never called at
runtime — but it still must exist, because the ABC requires it. Provide
a real implementation so that a future change to persist tokens works
without a follow-up edit.
### `Credentials` subclasses (optional)
Only needed when the provider's login payload isn't the usual
`{username, password}` shape. HRTi's `grant_access` takes
`{Username, Password, OperatorReferenceId}`, so it has a custom
`HRTiCredentials(UserPasswordCredentials)` with a `to_auth_payload()`
override. Most providers use `UserPasswordCredentials` directly.
## The manager ABCs
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
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. 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:
def __init__(
self,
*,
http_manager,
auth,
country,
config,
your_extra_collaborator=None, # optional, subclass-specific
):
super().__init__(
http_manager=http_manager,
auth=auth,
country=country,
config=config,
)
self._your_extra_collaborator = your_extra_collaborator
The base constructor accepts ONLY the four required collaborators. There
is no `**provider_opts` passthrough. This is deliberate: a typo at a call
site (`channel_cache=` instead of `channels_cache=`) raises `TypeError`
immediately rather than silently producing a half-configured manager.
Subclasses declare their extra keyword-only args explicitly, call
`super().__init__` with only the four required, and store extra state on
`self` after the super call.
Managers never hold a reference to the provider. If you need something
the provider owns, inject it at construction.
### Cross-manager collaborators
Some managers need collaborators from other managers. Common cases:
* **CatchupManager** needs `channels` (to resolve a live manifest URL or
a stream uid) and/or `epg` (to resolve an epg_id from a start_time).
* **RecordingsManager** *may* need `channels` if the provider resolves
a recording's manifest through the channel manager (simpliTV does
this by default; the recordings manager itself never implements
`get_manifest`, so the collaborator is only needed if the recordings
manager fetches supplementary channel metadata).
* **DrmManager** (dedicated architecture) may need `channels` or `vod`
to look up an asset's DRM config.
* **FavoritesManager** and **BookmarksManager** rarely need other
managers, but if the content_id needs resolving to a program id, they
may need a lookup helper.
Pass these as explicit keyword-only constructor arguments. The provider
builds them in dependency order:
self.channels = self._build_channels()
self.epg = self._build_epg()
self.recordings = self._build_recordings()
self.catchup = self._build_catchup() # sees channels + epg
...
Don't introduce cycles between managers. If two managers need each
other, one of them should own the shared state and the other should
borrow it via a cache argument, not via a mutual reference.
If you find that a manager needs a provider-owned cache that isn't
otherwise exposed, pass the cache dict itself as a keyword-only
collaborator (as `playback_cache` is passed to simpliTV's channel and
catchup managers). Do not reach back to the provider.
Managers may also need each other's **content-id parsers**. Put the
parsers at module scope in the primary content-id namespace file
(usually `channel_manager.py`), and import them where needed. Parsers
are pure functions — no cross-manager state is involved.
### Capability flags
The provider exposes a derived boolean per capability:
@property
def implements_channels(self) -> bool:
return self.channels is not None
@property
def implements_vod(self) -> bool:
return self.vod is not None
@property
def implements_epg(self) -> bool:
return self.epg is not None
@property
def implements_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:
# See the DRM section for the folded-vs-dedicated derivation.
The pattern is uniform: a capability is present iff its manager object is
present. There is no separate boolean to keep in sync.
## Return-value rule: None vs exception
This is the single most important convention. Every manager follows it.
**Return None / [] for "not in my domain."**
`get_channel_manifest("clip_123")` from a channel manager that only
handles live channels returns `None`. Not an error — the router uses it
to fall through to the VOD manager. Same for `get_vod_manifest`,
`get_channel_drm`, `get_vod_drm`, `get_epg`, `get_recordings`,
`get_favorites`, `get_bookmarks`.
**Raise for genuine failures.**
- `AuthError` — token expired or invalid. Caller refreshes and retries.
- `GeoBlockError` — content not available in the caller's region.
- `EntitlementError` — authenticated but not subscribed.
- `RateLimitError` — caller should back off.
- `ServerError`, `TransportError` — transient.
- `PlaybackRestrictedError` — refused for a non-entitlement reason.
- `NotFoundError` — the provider knows this content belongs in its domain
but has been removed.
- `BadRequestError` — 400 from the wrong endpoint. Not swallowed by the
router; providers that use 400 as a routing signal should override
`handles_content_id()` instead.
`NotFoundError` and `None` mean different things, and the router preserves
the distinction:
provider.get_manifest("gone_123") -> raises NotFoundError
provider.get_manifest("unknown_456") -> returns None
The router tracks any `NotFoundError` raised by a manager, tries the next
manager, and re-raises the last `NotFoundError` if nobody resolved. So a
caller can distinguish "removed" from "not in anyone's domain."
Rule of thumb: **"I don't handle this" is a return value; "this is a real
failure" is an exception.**
## Routing with handles_content_id()
The router pattern in the provider's `_route()` helper is used for
methods whose input is an **opaque content_id** with multiple possible
owners. In practice that means:
get_manifest(content_id) -> channel | vod | catchup (whichever owns it)
get_drm(content_id, ...) -> channel | vod (whichever owns it)
Not every capability needs routing:
* **Capabilities with their own ID namespace** (recordings) don't route.
`get_recordings()` and `delete_recording(recording_id)` are named
methods on the recordings manager. The caller names the capability
explicitly; there is no ambiguity to resolve.
* **Capabilities that operate on all content** (EPG, favorites,
bookmarks) don't route. `get_epg(channel_id, ...)`,
`add_favorite(content_id)`, `update_bookmark(content_id)` are named
methods that go straight to their manager.
* **Capabilities that need cross-manager collaborators** (catchup) route
through the provider if their content_id namespace overlaps with
another manager's, or are called directly on their manager otherwise.
The rule: **route when a content_id could belong to more than one
manager. Otherwise, name the capability explicitly.**
### handles_content_id
For managers that DO participate in routing, override
`handles_content_id()` when the manager's content_id grammar has a
distinguishable shape:
# In your VodManager:
def handles_content_id(self, content_id: str) -> bool:
return content_id.startswith(("details_", "clip_", "program_"))
# In your ChannelManager:
def handles_content_id(self, content_id: str) -> bool:
return content_id.startswith(("live:", "vod:", "series:"))
If your provider has no content_id grammar, leave it returning `True` and
the router falls back to try-and-catch.
Which managers typically override `handles_content_id`:
* **ChannelManager**, **VodManager**, **CatchupManager** — yes.
Content-id grammar usually has a prefix or shape that discriminates.
* **EpgManager** — has `handles_channel_id()`, used internally by
`get_epg_grid()`. Does not participate in `_route`.
* **RecordingsManager** — has `handles_recording_id()` for the case
where multiple recordings managers exist (cloud PVR + local PVR,
say). Does not participate in `_route` for content_id, because
recordings have their own ID namespace.
* **FavoritesManager**, **BookmarksManager** — usually no. They operate
on whatever content_id the caller provides; there is no grammar to
discriminate on.
`BadRequestError` is intentionally not caught by the router. Providers
that use a 400 response as an endpoint-dispatch signal (like Magenta's
page-vs-component guess) should override `handles_content_id()` instead,
so the router never has to guess.
### Parsers vs. dispatch
Two separate concerns that look similar:
* **Grammar**: what content_id shapes a manager will accept. Exposed
as `handles_content_id()`. A manager whose grammar covers several
prefixes returns True for all of them.
* **Dispatch**: which manager the router tries for a given id. This is
the router's own decision, expressed by what it passes to `_route()`
(or by explicit prefix branches above `_route`).
They can differ. In simpliTV:
* The channel manager's grammar covers `live:` and `rec:` — it accepts
both.
* The router routes both through `_route` uniformly, because they
resolve to the same endpoint.
* The catchup manager's grammar covers `catchup:<codename>@<ts>` — but
the router does NOT route catchup through `_route`, because it needs
to parse the `@<ts>` suffix and pass it as an argument.
The rule: **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, a
query-like suffix).** One parser, in the router.
### Content-id grammar parsers
Put content-id grammar parsers at module scope in the file that owns
the provider's primary content-id namespace (usually
`channel_manager.py`):
def parse_live_id(content_id): ...
def parse_recording_id(content_id): ...
def parse_catchup_id(content_id): ...
Other managers and the provider import them from there. Do not
duplicate the parsers per manager — one grammar, one parser, imported
everywhere it's needed.
Parsers should raise `BadRequestError` on malformed input. The router
does not catch `BadRequestError`, so a malformed id surfaces to the
caller rather than silently falling through to the wrong manager.
Parsers must live at module scope, not as class methods, so multiple
managers can import them without a circular dependency.
### Multiple managers with the same prefix
Two managers may claim the same content-id prefix if their
responsibilities are disjoint. simpliTV:
* `SimpliTVChannelManager` accepts `rec:<codename>` for manifest and
DRM — the streaming side.
* `SimpliTVRecordingsManager` owns `get_recordings` and
`delete_recording` — the list side.
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 the same prefix means two code paths for the same content, and they will
drift.
### Sentinels for unused ABC parameters
If the ABC's method signature requires a parameter your provider's API
does not use, prefer `None` for optional arguments. The
`CatchupManager.get_catchup_manifest` signature declares `end_time` as
`Optional[int]` for exactly this reason — providers whose catchup API
only takes a start bound can pass `None` and document that they ignore
it.
Never pass `None` where the ABC expects `int`. Never pass `0` without a
comment — `0` is ambiguous (a valid timestamp? a "no value" marker?).
Prefer `None` when the ABC permits it; if it doesn't, add a comment
explaining the sentinel.
### Routing for recordings manifests
Some providers (simpliTV) route a recording's manifest through the
channel manager, because the API shares the codename namespace between
a channel and its recordings. Others may route through the VOD manager
or a dedicated playback path.
The routing lives in the provider's `get_manifest`, not in the
recordings manager. The recordings manager owns the *list* and the
*delete*; manifest resolution is the router's job. See
`providers/simplitv/provider.py` for a concrete example — it routes the
`rec:` prefix to the channel manager, whose `handles_content_id`
accepts it alongside `live:`.
Do not add a `get_manifest` method to `RecordingsManager`.
### Private helpers used by multiple managers
If a helper function is used by more than one manager, it is public by
default. Name it without the underscore.
# Bad: catchup_manager imports _prefer_dash from channel_manager
from .channel_manager import _prefer_dash
# Good: the helper is public
from .channel_manager import prefer_dash
The underscore-prefixed convention is for helpers private to one file.
An underscore-prefixed name imported across modules is a signal the
helper should either be renamed public or moved to a shared module
(`constants.py`, or a new `helpers.py`).
## The Auth protocol
Auth is a documented protocol (see `base/protocols.py`), not an ABC.
**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
# Credential methods are no-ops for a no-auth provider.
def has_credentials(self):
return True
def set_credentials(self, username, password):
return False
def clear_credentials(self):
return False
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.
build_headers(token=None, **opts) -> Dict[str, str]
Request-ready headers including auth. If token is None, use the
cached token (via get_access_token).
invalidate() -> None
Drop cached token and session state. Called after 401s.
Optional extensions — implement only if needed:
get_scoped_token(scope, **opts) -> Optional[str]
Secondary tokens. RTL+ uses this for bedrock / upfront.
get_session_context() -> Optional[Dict[str, Any]]
Opaque session state needed by build_headers. Magenta uses this
for guest device/session ids; Discovery for cookie + session-state
headers.
authorize_playback(content_id, **opts) -> Dict[str, Any]
Provider-specific pre-playback step. HRTi's AuthorizeSession,
MoveTV's live-source fetch, Discovery's playbackInfo POST,
RTL+'s upfront token, Magenta's persona JWT retrieval.
There is no fixed interface for `authorize_playback`. The name is a
convention; the shape is provider-specific.
**Thread safety.** If the host serves requests from multiple threads
(which it does), and your auth flow involves more than one HTTP call
(e.g. login + device registration, or login + feature-flags fetch), wrap
`get_access_token()` and any other stateful accessor in a
`threading.RLock`. Without it, two threads hitting a cold cache will each
start the full flow, and the second one will either duplicate the work
or observe partial state. Providers whose auth flow is a single HTTP
call can skip the lock; providers whose flow has two or more round-trips
need it.
**Token placement deviation: not in a header.** Some providers pass the
token as a URL query parameter or body field rather than an
`Authorization` header. simpliTV does this — `build_headers()` returns
base headers only, and callers attach the token via
`auth.with_token(url)` / `auth.auth_body()`. If your provider deviates
this way, document it prominently in your `auth.py` module docstring
**and** add a one-line comment at every manager call site that uses
`build_headers()`, so a future reader doesn't assume a bearer token is
being sent.
When the query-parameter name differs per endpoint (simpliTV's
`GetRecordings` uses `tokenValue` instead of `token`), put both names in
`constants.py` and give `with_token()` a `param=` keyword so the caller
can pick. Do not hardcode the parameter name in the auth class.
**Content-Type deviation: JSON body, non-JSON header.** Some endpoints
reject `Content-Type: application/json` even though the body is valid
JSON, and return a different (often empty) response when the header is
"correct". simpliTV's `/Authenticate` is the canonical example: it
requires `Content-Type: text/plain` with a JSON body. If your provider
has an endpoint like this, send the body as a raw string
(`data=json.dumps(payload)`) with the header set explicitly **for that
call only**, and add a comment explaining why — a future reader will
otherwise "fix" the header and silently break the login.
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. 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.
### Credentials — how they get loaded, saved, and re-checked
The `AuthProtocol` names the three *token* methods. It does not name the
*credential* methods, because credentials only exist for providers that
have them. But every provider that has credentials must implement the
credential surface correctly, or the auth class works only when the
caller passes credentials directly at construction — which never happens
in the real runtime. This is the failure mode most likely to slip
through a naive implementation: the provider instantiates cleanly,
`_build_auth` returns an object, and then the first `get_access_token()`
raises `CredentialsError` because nothing ever populated the
credentials.
The three credential methods:
has_credentials() -> bool
Return True if this auth can authenticate right now — i.e.
credentials are available from the constructor argument, the
settings manager, or a fallback that always succeeds.
Called by the UI and the registry to decide whether the
provider is usable.
set_credentials(username, password) -> bool
Persist credentials via
settings_manager.save_provider_credentials(provider_name,
UserPasswordCredentials(username, password), country).
Called by the settings UI when the user enters credentials.
Return True on success.
clear_credentials() -> bool
Clear stored credentials via
settings_manager.clear_provider_credentials(...) or
credential_manager.delete_credentials(provider_name, country).
Also call self.invalidate() to drop the cached token.
Return True on success.
**Credentials source priority, checked in this order:**
1. **Constructor argument.** The provider's `_build_auth()` passes
`credentials=self._credentials`, which is non-None only when the
caller constructed the provider with explicit credentials. This is
the case for CLI tools and tests, not for the runtime UI flow.
2. **Injected `settings_manager`.** If the host passed a
`settings_manager` to the provider's constructor (or the provider
forwards one to `_build_auth`), call
`settings_manager.get_provider_credentials(provider_name, country)`.
This reads credentials the settings UI wrote through
`save_provider_credentials`. **This path is preferred when present,
but is not guaranteed to be present.**
3. **`CredentialManager` (direct file read).** The host's provider
registry constructs providers *without* a settings_manager, so in
normal runtime the injected-manager path above is a no-op. Fall back
to reading `credentials.json` directly:
from ...base.auth.credential_manager import CredentialManager
creds = CredentialManager().load_credentials(provider, country)
`CredentialManager.load_credentials` tries the country-nested format
(`{"provider": {"country": {...}}}`) first and falls back to the flat
format (`{"provider": {...}}`), so a single call covers both storage
layouts. Do not branch on the format yourself.
4. **Fallback.** Providers with anonymous or free access return a
fallback credentials object from `get_fallback_credentials()` (as
`BaseAuthenticator` does). The fallback is what lets the auth class
succeed even when the user hasn't configured anything — useful for
providers that offer a limited anonymous tier.
**Implementing this in the template `auth.py`.** Wrap paths 2 and 3 in
a single `_load_stored_credentials()` helper that tries the
settings_manager first (guarded by `hasattr` and a try/except, because
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
`self._credentials = None` at construction and never re-reads, the
provider stays broken until the app restarts. The pattern from
`BaseAuthenticator` is:
def _ensure_credentials(self) -> bool:
# 1. If current credentials are valid, keep them.
if self._credentials and self._credentials.validate():
return True
# 2. Otherwise try the settings manager / CredentialManager,
# in case the user just stored them.
fresh = self._load_stored_credentials()
if fresh and fresh.validate():
self._credentials = fresh
return True
# 3. Otherwise try the fallback.
self._credentials = self.get_fallback_credentials()
return self._credentials is not None and self._credentials.validate()
Call `_ensure_credentials()` at the start of `_perform_authentication()`,
before building the login payload. This makes the auth class work
whether credentials were supplied at construction, stored by the UI
before first use, or stored by the UI after the provider was already
running.
**Reference.** The full pattern lives in
`base/auth/base_auth.py`:
`_load_credentials_from_manager`, `_ensure_credentials`,
`save_credentials`, `clear_stored_credentials`, `has_stored_credentials`.
Read them before writing your auth class.
**Providers with no user credentials** (anonymous-only, static-key,
free):
* `has_credentials()` returns `True` unconditionally.
* `set_credentials()` and `clear_credentials()` are no-ops returning
`False` (there is nothing to store).
* `_ensure_credentials()` in `_perform_authentication` is not needed —
the login flow doesn't depend on stored credentials.
### Per-authenticate credential flow
A correct `_perform_authentication()` looks like this:
def _perform_authentication(self):
if not self._ensure_credentials():
raise CredentialsError(
f"no credentials available for {self.provider_name}"
)
payload = self._build_login_payload(self._credentials)
resp = self.http_manager.post(
self._login_url(), json=payload, headers=self.build_headers()
)
return self._create_token_from_response(resp.json())
The critical piece is `_ensure_credentials()`. Without it, the auth
class silently depends on the caller having passed credentials — which
the registry never does.
### Session persistence is optional
Not every provider persists tokens. simpliTV, for example, uses a plain
opaque token with no refresh flow, so re-authenticating is cheap and
the auth class caches the token in-process only. No
`_save_session()` / `_load_session()` calls.
If your provider *does* persist tokens, use the settings_manager:
# In get_access_token, after _perform_authentication():
self.settings_manager.save_token_data(
provider_name, token.to_dict(), country
)
# At construction:
stored = self.settings_manager.load_token_data(provider_name, country)
if stored:
self._cached_token = self._token_from_dict(stored)
Decide whether persistence is worth the complexity. Providers with
cheap re-auth should skip it. Providers whose auth flow is expensive
(multi-step, has a rate limit, or requires user interaction like a
device code) should persist.
## DRM
DRM is optional. Providers with no DRM leave `_build_drm()` returning
`None` and don't override `get_channel_drm` / `get_vod_drm`. The
provider's `get_drm()` returns `[]` and `implements_drm` is `False`.
Providers with DRM make two independent choices.
### Architecture: dedicated manager vs. folded into managers
Rule: Does the DRM step share state with the manifest step?
yes -> fold into the channel/vod managers
no -> use a dedicated DRM manager
Tie-breaker: when both work, prefer the dedicated manager, because it
keeps DRM logic in one place. Folded exists for cases where the
alternative would be plumbing session state, an upfront token, or a
playbackInfo response between two managers that both need it.
"State" means any value the DRM step would otherwise have to receive
from the manifest step: a session id, an upfront token, a playbackInfo
response, an account/licence structure, and so on.
### Source pattern: how the licence URL is produced
Four source patterns appear across the existing providers. The source
pattern is orthogonal to the architecture — any source can be wired
into either architecture. See `drm_manager.py` in this directory for
file-level references and mechanics:
A. Per-content upfront token (RTL+)
B. Constructed from token claims + /user/account (Magenta)
C. Arrives with the playbackInfo response (Discovery, simpliTV)
D. Session id becomes a base64 auth blob (HRTi)
New-provider guidance: pick the architecture first (state-sharing rule
above), then pick the source pattern that most closely matches how the
provider's licence URL is produced, and adapt the mechanics.
The existing providers currently sit in a mix of shapes — some have DRM
logic directly on the provider class, some in a playback manager, some
in a VOD manager that predates this template. "Source pattern" describes
what their code does, not what class it lives in. Migrating any provider
onto the dedicated or folded architecture is optional; when migrated,
each will map to whichever architecture the state-sharing rule selects.
### The three method names
Three names appear in the DRM path. They are not interchangeable:
get_drm_configs -- the dedicated DrmManager's only method
(matches DrmManagerProtocol)
get_channel_drm -- the folded architecture's live-channel entry point
get_vod_drm -- the folded architecture's VOD entry point
New providers using the dedicated-manager architecture implement
`get_drm_configs` and leave the other two alone. Providers using the
folded architecture override `get_channel_drm` and/or `get_vod_drm` and
leave `get_drm_configs` alone.
`StreamingProvider.get_drm(content_id, content_type=None)` is the public
method callers use; it dispatches to whichever architecture the provider
chose.
### content_type hint semantics
`content_type` is an optional hint. Pass it when you already know the
content type (e.g. the backend streaming route, which has already
resolved the item). It is a *narrowing* hint, not a required argument.
On the folded path, only two values narrow the search:
"live" -> channel manager only
"vod" -> VOD manager only
Any other value -- including None, "event", "catchup", or a typo --
tries both. This is deliberate: widening on unknown input is always
safe, but a wrong narrowing produces a silent `[]` for protected
content, which is the hardest kind of bug to trace.
On the dedicated-manager path, the hint is passed through to
`get_drm_configs`. The manager may honour it, ignore it, or infer the
type from `content_id` grammar when `content_type is None`. Managers are
encouraged to widen when in doubt, for the same reason as above.
## Catchup
Catchup is a distinct capability with its own manager. It is optional —
providers without catchup leave `_build_catchup()` returning `None` and
`implements_catchup` is `False`.
The catchup step is a *modified* live-manifest URL (with time
parameters) for some providers, a distinct URL from a different origin
for others, and an EPG-based resolution for others. The ABC names the
entry point; the shape is provider-specific.
**Do not fall back to the live manifest.** If `get_catchup_manifest`
cannot resolve catchup for the requested window, return `None`. Do not
return the live manifest URL — the DRM pipeline would extract PSSH from
the live stream, which may differ from the catchup stream's encryption
context. Callers that want the live manifest on failure should call
`provider.get_manifest()` themselves.
`end_time` is **Optional[int]** on the ABC. Providers whose catchup API
only takes a start bound (simpliTV) pass `None` and document that they
ignore the argument. Providers whose API uses both bounds require the
caller to pass it and raise `BadRequestError` on `None`. Do not pass a
sentinel value (`0`, `start_time + 1800`) when the ABC accepts `None`.
Catchup usually needs collaborators from other managers:
* **channels** — to resolve a live manifest URL (Magenta, simpliTV) or
a stream uid (MoveTV).
* **epg** — to resolve an `epg_id` from a `start_time` (MoveTV).
Pass these as explicit keyword-only constructor arguments. See "Cross-
manager collaborators" above.
## Recordings
Recordings are a distinct capability with their own manager. Optional.
Recording identity is separate from content identity:
* `recording_id` is what you pass to `delete_recording`.
* `content_id` is what you pass to `get_manifest` to play the recording.
These are usually different namespaces. Keep them separate.
`get_recordings()` returns `Channel` objects (or a subclass carrying
`recording_id` and programme metadata). The base Channel shape is
preserved because downstream callers expect a `content_id` and a `name`.
`delete_recording` raises `KeyError` if the recording doesn't exist and
a `ProviderError` subclass on backend failure. It does **not** silently
return on failure — deleting a recording the user asked to delete and
having the deletion fail must surface.
**No manifest method.** The recordings manager deliberately has no
`get_manifest`. Recordings are played via a content_id that the
provider's router resolves — usually through the channel manager
(simpliTV) or the VOD manager, depending on how the provider's API
exposes the manifest. See "Routing for recordings manifests" above.
**Two managers may touch the `rec:` prefix.** The channel manager
accepts `rec:` for manifest and DRM fetch (the recording shares an
endpoint with its live channel). The recordings manager owns the list
and delete. This is not a conflict — they touch disjoint concerns.
Only one of them participates in the routing; the provider's
`get_manifest` is authoritative about which. See "Multiple managers
with the same prefix" above.
## Favorites and bookmarks
Both are optional, both are user-scoped, both follow the standard
manager contract.
Favorites: `FavoriteType` on each returned `Favorite` distinguishes
program / channel / clip / live / event. Providers that only support
one type validate the incoming type in `add_favorite` and reject the
others. Removing a non-existent favorite raises `KeyError`.
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`. Backend
failures raise a `ProviderError` subclass from `base.errors`, like every
other manager.
## Conventions
- **Custom Channel / AuthToken subclasses are fine.** Call
`super().to_dict()` in your override. MoveTV, Discovery, HRTi, and
simpliTV all do this.
- **Provider owns caches; managers borrow them.** Create caches in the
provider's `__init__`; pass them into manager constructors by
reference.
- **Content ID grammar is provider-specific.** Pick one and document it
in the manager's docstring, or in a module-scope parser (see
"Content-id grammar parsers" above).
- **Playback authorization is provider-specific.** Don't force it into a
shared interface. See the existing providers for examples.
- **Extra capabilities that don't fit an ABC.** If the provider has a
capability area that doesn't correspond to a base-layer mixin and
doesn't warrant a new ABC, put the methods on the concrete manager
that owns the domain, and pass them through on the provider. Document
the capability in the manager's class docstring. Don't invent an
ad-hoc manager class.
## Errors
Raise from `base.errors`:
AuthError, CredentialsError, SessionExpiredError,
GeoBlockError, EntitlementError, AccountRestrictedError,
PlaybackRestrictedError,
NotFoundError, BadRequestError,
RateLimitError, ServerError, TransportError,
CatchupRequiredError, NotImplementedYetError, ConfigurationError
Subclass when you need to carry payload:
class MyProviderCatchupError(CatchupRequiredError): ...
Existing callers that catch the base class catch your subclass too.
`ProviderError.__reduce__` preserves subclass payload across pickle and
`copy.deepcopy`, so payload-carrying subclasses are safe to pass through
queues and process boundaries.
## VOD return type
Every VOD navigation method returns `VodPage` (base/vod.py):
return VodPage(entries=[...], next_cursor="2", total=120)
return VodPage(entries=[...]) # no pagination
return VodPage() # empty
Pagination rule: use `page.has_more` — NOT `bool(page)` — to decide
whether to keep paging. `bool(page)` follows list semantics (empty page
is falsy, page with entries is truthy), which is correct for "are there
entries to display?" but wrong for pagination, because a page can have
zero entries and a non-None `next_cursor`.
`total` may be `None`. It is informational; do not use it as an
end-of-list signal. `next_cursor is None` (equivalently, `not
page.has_more`) is the authoritative signal.
## Lazy auth
The template does NOT authenticate in `__init__`. The first
`build_headers()` / `get_access_token()` call triggers authentication.
This avoids network I/O in constructors and lets you create a provider
object for inspection without hitting the network.
If your provider has a strong reason to authenticate eagerly (e.g. you
need to fail fast on bad credentials), do it in the provider's `__init__`
inside a try/except and log a warning — do not raise.
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:
simpliTV -- built from this template. Recordings + catchup + folded
DRM. Content-id grammar with three prefixes, module-scope
parsers, and a router that parses the catchup timestamp.
Token-in-body deviation, per-endpoint param naming,
custom AuthToken subclass, direct-CredentialManager
credential loading. Good first read.
MoveTV -- dynamic manifests, play-auth headers, EPG-based catchup.
Magenta EU -- EPG-heavy, VOD with typed errors, multi-country.
HRTi -- session-authorize playback, custom credentials shape.
Discovery -- dynamic endpoint discovery, Arkose challenge, playbackInfo.
RTL+ -- layout-driven, three tokens, largest surface.
Read the closest one before writing a new provider. Each demonstrates a
different source pattern for DRM, a different content-id grammar, and a
different shape for playback authorization.