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
VodPageshape, sharedContent/Channeldata model.
What you implement
- Provider —
provider.py. Required. Declares the provider's class members, wires up whatever managers it has, and exposes the public interface. - Auth —
auth.py. Optional. Required only if the provider authenticates requests. Free / static-key providers can omit it entirely, or provide a minimalAuththat only sets base headers. - 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 noChannelManager; a linear-only provider has noVodManager; a favorites-sync-only provider may have neither. See "The manager ABCs" below. - Constants —
constants.py. URLs, endpoints, static headers. - Models — often required. A custom
AuthTokensubclass is mandatory whenever the auth class returns aBaseAuthToken, becauseBaseAuthTokenis an ABC with an abstractto_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.
countryis 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/mecall).
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 viaset_static_manifest(url).manifest_script: Optional[str]— for dynamic manifests, provider- specific parameters fetched at request time. Set viaset_dynamic_manifest(params).session_manifest: bool— True when the manifest must be fetched per-session. Mutually exclusive withmanifestin practice: ifsession_manifest=Trueandmanifestis also set,Channel's_validate_fieldslogs a warning and the static URL is ignored.streaming_format: Optional[str]—"dash","hls", or None. Serialized as"StreamingFormat"into_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:
-
Call
super().to_dict()and add fields in TitleCase. The base serializer emits TitleCase; subclasses must match. -
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.
-
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. -
Use the inherited factory methods.
YourChannel.create_live_channel(...)returns aYourChannelbecause the factories usecls(...). 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
==, notis.channel.mode == StreamingMode.LIVEworks;channel.mode is StreamingMode.LIVEis fragile (string interning makes it usually work, but not guaranteed). isinstance(x, StreamingMode)never works. There is no instance ofStreamingMode— it's a namespace, not a type.- Do not
import Enumand try toStreamingMode.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/orepg(to resolve an epg_id from a start_time). - RecordingsManager may need
channelsif the provider resolves a recording's manifest through the channel manager (simpliTV does this by default; the recordings manager itself never implementsget_manifest, so the collaborator is only needed if the recordings manager fetches supplementary channel metadata). - DrmManager (dedicated architecture) may need
channelsorvodto 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 overridehandles_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()anddelete_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 byget_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_routefor 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:andrec:— it accepts both. - The router routes both through
_routeuniformly, 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:
SimpliTVChannelManageracceptsrec:<codename>for manifest and DRM — the streaming side.SimpliTVRecordingsManagerownsget_recordingsanddelete_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.pyheaders. - 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
Nonefrom_build_auth(). - Either accept the warning (it is non-fatal — the manager still constructs and runs), or
- Provide a minimal
Authstub that only implementsbuild_headers()and returns the static headers. This is usually cheaper than suppressing the warning, because managers that callauth.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:
-
Constructor argument. The provider's
_build_auth()passescredentials=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. -
Injected
settings_manager. If the host passed asettings_managerto the provider's constructor (or the provider forwards one to_build_auth), callsettings_manager.get_provider_credentials(provider_name, country). This reads credentials the settings UI wrote throughsave_provider_credentials. This path is preferred when present, but is not guaranteed to be present. -
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 readingcredentials.jsondirectly:from ...base.auth.credential_manager import CredentialManager creds = CredentialManager().load_credentials(provider, country)CredentialManager.load_credentialstries 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. -
Fallback. Providers with anonymous or free access return a fallback credentials object from
get_fallback_credentials()(asBaseAuthenticatordoes). 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()returnsTrueunconditionally.set_credentials()andclear_credentials()are no-ops returningFalse(there is nothing to store)._ensure_credentials()in_perform_authenticationis 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_idfrom astart_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_idis what you pass todelete_recording.content_idis what you pass toget_manifestto 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.