add template provider

This commit is contained in:
Nirvana
2026-10-03 14:35:57 +02:00
parent b5aa76ac11
commit 7d08dbec71
18 changed files with 2224 additions and 2 deletions
+4 -2
View File
@@ -37,8 +37,10 @@ def _discover_providers():
for item in os.listdir(providers_dir):
provider_path = os.path.join(providers_dir, item)
# Skip if not a directory or if it starts with __
if not os.path.isdir(provider_path) or item.startswith("__"):
# Skip if not a directory. Skip private/dunder directories -- names
# starting with "_" are reserved for scaffolding (e.g. _template)
# and are never treated as real providers.
if not os.path.isdir(provider_path) or item.startswith("_"):
continue
# Check if __init__.py exists in the provider directory
+262
View File
@@ -0,0 +1,262 @@
# streaming_providers/base/errors.py
"""
Shared provider error hierarchy.
Rationale
---------
Every provider raises its own exception types for the same handful of
conditions: expired auth, geo-block, entitlement denial, content removed,
rate limits, server faults, playback restrictions, catchup-required items.
Downstream code (operations layer, Kodi plugin, UI) then has to special-case
per provider.
This module provides one hierarchy that all providers can subclass. Providers
MAY keep their own exception class names (existing callers can still catch
those); the only requirement is that the provider's classes inherit from the
appropriate base here.
This is a *convention*, not a mechanical enforcement. Nothing prevents a
provider from raising a bare Exception. But if a provider raises from this
hierarchy, callers get uniform handling for free.
Usage in a provider:
from ...base.errors import AuthError, GeoBlockError
class MyVodAuthError(AuthError): ...
class MyVodGeoBlockError(GeoBlockError): ...
Usage in a caller:
from ...base.errors import AuthError, GeoBlockError, ProviderError
try:
manifest = provider.get_manifest(content_id)
except AuthError:
... # refresh credentials / prompt for re-login
except GeoBlockError:
... # not available in your region
except ProviderError as e:
... # generic fallback; e.code may carry a provider-specific code
Error codes
-----------
Some providers (Discovery+) emit machine-readable codes alongside the
exception type. This base supports an optional `code` attribute. Providers
that don't use codes leave it None.
"""
from __future__ import annotations
from typing import Any, Optional, Tuple
class ProviderError(Exception):
"""
Base class for all provider errors.
Every subclass accepts (message, *, status=None, url=None, code=None) and
stores them as attributes so callers can inspect without re-parsing the
message string.
Note on pickling / copy.deepcopy:
Subclasses are allowed to have different __init__ signatures
(e.g. ChannelNotFoundError(channel_id)) and to carry extra payload
attributes. __reduce__ below reconstructs via __new__ and restores
__dict__ as state, so it works regardless of the subclass's
constructor shape and preserves every attribute.
"""
def __init__(
self,
message: str,
*,
status: Optional[int] = None,
url: Optional[str] = None,
code: Optional[str] = None,
) -> None:
super().__init__(message)
self.status = status
self.url = url
self.code = code
def __repr__(self) -> str:
msg = self.args[0] if self.args else ""
parts = [self.__class__.__name__, f"({msg!r}"]
if self.status is not None:
parts.append(f", status={self.status}")
if self.code is not None:
parts.append(f", code={self.code!r}")
parts.append(")")
return "".join(parts)
def __reduce__(self) -> Tuple[Any, ...]:
"""
Preserve ALL attributes across pickle / copy.deepcopy.
Returns a 3-tuple (callable, args, state). The annotation is
Tuple[Any, ...] because __reduce__ can return a 2-tuple or a
3-tuple depending on whether state is provided; the base
object.__reduce__ signature reflects this.
Exception.__reduce__ uses args only, which drops keyword-only fields
(status, url, code) and any subclass payload. Reconstruction goes
through __new__ so the subclass's __init__ is not called -- this
means subclass constructors with different signatures (e.g.
PlaybackRestrictedException(reason, error_code)) work fine, and
__dict__ is restored as-is.
"""
return (
_rebuild_provider_error,
(self.__class__, self.args),
self.__dict__.copy(),
)
def _rebuild_provider_error(
cls: type, args: Tuple
) -> "ProviderError":
"""
Reconstruction helper for ProviderError.__reduce__.
Deliberately does not call cls(...). Creates a bare instance and sets
args; pickle then restores __dict__ as state, so every attribute the
original had comes back regardless of the subclass constructor.
"""
exc = cls.__new__(cls)
exc.args = tuple(args)
return exc
# ---------------------------------------------------------------------------
# Auth / session
# ---------------------------------------------------------------------------
class AuthError(ProviderError):
"""401-style failure: token expired, invalid, or missing."""
class CredentialsError(AuthError):
"""Invalid username/password (as opposed to a stale token)."""
class SessionExpiredError(AuthError):
"""Server-side session is gone; a full re-login is required."""
# ---------------------------------------------------------------------------
# Access control
# ---------------------------------------------------------------------------
class GeoBlockError(ProviderError):
"""Content is not available in the caller's region."""
class EntitlementError(ProviderError):
"""Authenticated, but not entitled to this content."""
class AccountRestrictedError(EntitlementError):
"""Account-level gate (e.g. VOD disabled for the whole account)."""
class PlaybackRestrictedError(ProviderError):
"""Playback is refused for a reason not covered above."""
# ---------------------------------------------------------------------------
# Content lookup
# ---------------------------------------------------------------------------
class NotFoundError(ProviderError):
"""Content is known to the provider but has been removed.
At the manager top level, "this manager doesn't handle that content_id"
is signalled by returning None/[] -- not by raising NotFoundError. See
the "None vs exception" section in providers/_template/README.md.
NotFoundError is for the case where the provider *knows* the content
belongs in its domain but has been removed (e.g. a VOD detail fetch
returns 404 for an id that was recently listed). The orchestrator's
router will try other managers, then re-raise this if nobody resolves.
"""
class BadRequestError(ProviderError):
"""400 -- the request was malformed for the endpoint called.
Used as a routing signal by providers that guess endpoint shape from an
opaque content_id (e.g. Magenta's page-vs-component dispatch). The
orchestrator's router does NOT swallow this -- providers that need that
behavior override handles_content_id() instead.
"""
# ---------------------------------------------------------------------------
# Transport / server
# ---------------------------------------------------------------------------
class RateLimitError(ProviderError):
"""429 -- caller should back off and retry."""
class ServerError(ProviderError):
"""5xx -- retryable."""
class TransportError(ProviderError):
"""Connection-level failure (DNS, TLS, timeout) -- no HTTP status."""
# ---------------------------------------------------------------------------
# Flow control
# ---------------------------------------------------------------------------
class CatchupRequiredError(ProviderError):
"""This 'VOD' item is actually a catch-up entry from a linear channel."""
class NotImplementedYetError(ProviderError):
"""Feature captured but not yet wired up."""
class ConfigurationError(ProviderError):
"""Provider is misconfigured."""
# ---------------------------------------------------------------------------
# HTTP status -> error class heuristic
# ---------------------------------------------------------------------------
def default_error_for_status_heuristic(
status: int,
message: str,
*,
url: Optional[str] = None,
body_snippet: str = "",
) -> ProviderError:
"""
Heuristic mapping from HTTP status to ProviderError subclass.
This is a heuristic, not a contract. Providers with their own error
classification should construct the specific error directly rather than
relying on this. The 403 branch in particular uses a substring match on
body_snippet and is intentionally shallow so providers do not depend on
it accidentally.
"""
if status == 400:
return BadRequestError(message, status=status, url=url)
if status == 401:
return AuthError(message, status=status, url=url)
if status == 403:
lower = body_snippet.lower()
if "geo" in lower or "region" in lower:
return GeoBlockError(message, status=status, url=url)
return EntitlementError(message, status=status, url=url)
if status == 404:
return NotFoundError(message, status=status, url=url)
if status == 429:
return RateLimitError(message, status=status, url=url)
if 500 <= status < 600:
return ServerError(message, status=status, url=url)
return ProviderError(message, status=status, url=url)
@@ -0,0 +1,37 @@
# streaming_providers/base/managers/__init__.py
"""
Manager ABCs for streaming providers.
Each manager wraps one capability area (channels, VOD, EPG) with a fixed
public interface. Providers subclass and implement the abstract methods;
the concrete methods (headers, DRM defaults, search no-ops) come for free.
Design rules
------------
* Constructors take four required collaborators -- http_manager, auth,
country, config -- plus keyword-only extras (caches, collaborators).
* Managers never hold a reference to the provider. Shared state is passed
in at construction.
* Managers raise from base.errors; nothing catches broadly.
* VOD navigation always returns VodPage (base/vod.py).
On the None-vs-exception rule
-----------------------------
Manager top-level methods (get_*_manifest, get_*_drm) signal "this manager
doesn't handle that content_id" by returning None / [] -- NOT by raising
NotFoundError. See providers/_template/README.md for the full rule.
Rigidity note
-------------
The ABCs enforce the *method names and signatures* through the abstract
method mechanism. They do NOT enforce that providers raise the right
error classes, or that they return the right content shapes. Those are
conventions documented in the template. Treat the ABCs as "the interface
is fixed" not as "everything about a manager is enforced."
"""
from .channel import ChannelManager
from .vod import VodManager
from .epg import EpgManager
__all__ = ["ChannelManager", "VodManager", "EpgManager"]
@@ -0,0 +1,118 @@
# streaming_providers/base/managers/channel.py
"""
ChannelManager ABC.
Public interface
----------------
handles_content_id(content_id) -> bool [concrete]
get_channels(**kw) -> List[Channel] [abstract]
get_channel_manifest(content_id, **kw) -> Optional[str] [abstract]
get_channel_manifest_headers(content_id, **kw) -> Dict[str, str] [concrete]
get_segment_headers(content_id, **kw) -> Dict[str, str] [concrete]
get_channel_drm(content_id, **kw) -> List[DRMConfig] [concrete]
Constructor contract
--------------------
Four required keyword-only collaborators. Subclasses that need extra state
declare additional keyword-only args and store them on self AFTER calling
super().__init__. The base does NOT accept **kwargs -- a typo at a call
site becomes an immediate TypeError, which is what you want.
Return-value conventions
------------------------
get_channel_manifest / get_channel_drm return None / [] when this manager
does not handle content_id. Auth / geo / entitlement / rate-limit / server
failures propagate as exceptions from base.errors.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from ..models import Channel, DRMConfig
from ..protocols import AuthProtocol
from ..utils.logger import logger
class ChannelManager(ABC):
"""Abstract base for provider channel managers."""
def __init__(
self,
*,
http_manager: Any,
auth: AuthProtocol,
country: str,
config: Any,
) -> None:
# Sanity check the Auth collaborator against the runtime_checkable
# protocol. isinstance on a runtime_checkable Protocol only verifies
# method presence, not signatures -- that's the intended check here.
if not isinstance(auth, AuthProtocol):
logger.warning(
f"{self.__class__.__name__}: auth does not match AuthProtocol "
f"(missing one of get_access_token / build_headers / "
f"invalidate). Got {type(auth).__name__}."
)
self.http_manager = http_manager
self.auth = auth
self.country = country
self.config = config
# ------------------------------------------------------------------
# Routing
# ------------------------------------------------------------------
def handles_content_id(self, content_id: str) -> bool:
"""
True if this manager handles the given content_id.
Default: True. Override in providers whose channel content_ids have
a distinguishable grammar (e.g. numeric-only for live channels).
The orchestrator uses this to route get_manifest / get_drm without
a wasted request to the wrong manager.
"""
return True
# ------------------------------------------------------------------
# Abstract
# ------------------------------------------------------------------
@abstractmethod
def get_channels(self, **kw: Any) -> List[Channel]:
"""Return the provider's live channels (Channel or subclass)."""
raise NotImplementedError
@abstractmethod
def get_channel_manifest(
self, content_id: str, **kw: Any
) -> Optional[str]:
"""
Return the manifest URL for a channel, or None if this manager
doesn't handle content_id. Do NOT raise NotFoundError for "not in
my domain" -- the router uses the None return to fall through.
"""
raise NotImplementedError
# ------------------------------------------------------------------
# Concrete
# ------------------------------------------------------------------
def get_channel_manifest_headers(
self, content_id: str, **kw: Any
) -> Dict[str, str]:
"""Headers for the manifest request. Default: auth headers."""
return self.auth.build_headers()
def get_segment_headers(
self, content_id: str, **kw: Any
) -> Dict[str, str]:
"""Headers for segment requests. Default: manifest headers."""
return self.get_channel_manifest_headers(content_id, **kw)
def get_channel_drm(
self, content_id: str, **kw: Any
) -> List[DRMConfig]:
"""DRM for a channel. Default: no DRM."""
return []
@@ -0,0 +1,127 @@
# streaming_providers/base/managers/epg.py
"""
EpgManager ABC.
Public interface
----------------
epg_window -> Tuple[int, int] [concrete]
implements_epg -> bool [concrete]
handles_channel_id(channel_id) -> bool [concrete]
get_epg(channel_id, start_time, end_time, **kw)
-> List[EPGEntry] [abstract]
get_epg_grid(channel_ids, start_time, end_time, **kw)
-> Dict[str, List[EPGEntry]] [concrete]
get_program_details(program_id, **kw) -> Optional[EPGProgramDetails] [concrete]
Constructor contract
--------------------
Four required keyword-only collaborators. No **kwargs.
epg_window returns (past_days, future_days). (0, 0) means no EPG support.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from ..models.epg_models import EPGEntry, EPGProgramDetails
from ..protocols import AuthProtocol
from ..utils.logger import logger
class EpgManager(ABC):
"""Abstract base for provider EPG managers."""
def __init__(
self,
*,
http_manager: Any,
auth: AuthProtocol,
country: str,
config: Any,
) -> None:
if not isinstance(auth, AuthProtocol):
logger.warning(
f"{self.__class__.__name__}: auth does not match AuthProtocol "
f"(missing one of get_access_token / build_headers / "
f"invalidate). Got {type(auth).__name__}."
)
self.http_manager = http_manager
self.auth = auth
self.country = country
self.config = config
# ------------------------------------------------------------------
# Capability
# ------------------------------------------------------------------
@property
def epg_window(self) -> Tuple[int, int]:
"""(past_days, future_days). Default (0, 0) means no EPG."""
return 0, 0
@property
def implements_epg(self) -> bool:
"""True when this manager actually provides EPG data."""
return self.epg_window != (0, 0)
def handles_channel_id(self, channel_id: str) -> bool:
"""
True if this manager provides EPG for the given channel.
Default: True. Override when only a subset of channels has EPG
(e.g. radio-only, or a whitelist). Used by the default
get_epg_grid() to skip unhandled channels without a call.
"""
return True
# ------------------------------------------------------------------
# Abstract
# ------------------------------------------------------------------
@abstractmethod
def get_epg(
self,
channel_id: str,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
**kw: Any,
) -> List[EPGEntry]:
"""Return EPG entries for one channel, or [] if none."""
raise NotImplementedError
# ------------------------------------------------------------------
# Concrete
# ------------------------------------------------------------------
def get_epg_grid(
self,
channel_ids: List[str],
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
**kw: Any,
) -> Dict[str, List[EPGEntry]]:
"""
Batch EPG for multiple channels.
Default: loops get_epg per channel, skipping channels for which
handles_channel_id() is False (returns [] for those without calling
get_epg). Override when the provider has a native batch endpoint.
"""
result: Dict[str, List[EPGEntry]] = {}
for cid in channel_ids:
if not self.handles_channel_id(cid):
result[cid] = []
continue
result[cid] = self.get_epg(
cid, start_time=start_time, end_time=end_time, **kw
)
return result
def get_program_details(
self, program_id: str, **kw: Any
) -> Optional[EPGProgramDetails]:
"""Rich metadata for one programme. Default: not supported."""
return None
@@ -0,0 +1,130 @@
# streaming_providers/base/managers/vod.py
"""
VodManager ABC.
Public interface
----------------
handles_content_id(content_id) -> bool [concrete]
get_vod_category(content_id="", cursor=None, page_size=24, **kw)
-> VodPage [abstract]
get_vod_manifest(content_id, **kw) -> Optional[str] [abstract]
search_vod(query, cursor=None, page_size=24, **kw) -> VodPage [concrete]
get_vod_manifest_headers(content_id, **kw) -> Dict[str, str] [concrete]
get_vod_drm(content_id, **kw) -> List[DRMConfig] [concrete]
content_id grammar
------------------
content_id is an opaque token. Each provider picks its own grammar and
documents it here. The base class does not parse content_id. Examples:
RTL+ "folder_<id>", "program_<id>", "clip_<id>", ...
HRTi "catalogue_<id>", "series_<sid>--<ssid>", ...
Discovery "/sports", "/sports/alpine-skiing", ...
MoveTV ["root", "Film", "123"] encoded into a string
Constructor contract
--------------------
Four required keyword-only collaborators. No **kwargs. Subclasses accept
extra keyword-only args explicitly and call super().__init__ with only
the four required. A typo at a call site becomes an immediate TypeError.
Return-value conventions
------------------------
get_vod_manifest / get_vod_drm return None / [] when this manager does not
handle content_id. Auth / geo / entitlement / rate-limit / server failures
propagate as exceptions from base.errors.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from ..models import DRMConfig
from ..protocols import AuthProtocol
from ..utils.logger import logger
from ..vod import VodPage
class VodManager(ABC):
"""Abstract base for provider VOD managers."""
def __init__(
self,
*,
http_manager: Any,
auth: AuthProtocol,
country: str,
config: Any,
) -> None:
if not isinstance(auth, AuthProtocol):
logger.warning(
f"{self.__class__.__name__}: auth does not match AuthProtocol "
f"(missing one of get_access_token / build_headers / "
f"invalidate). Got {type(auth).__name__}."
)
self.http_manager = http_manager
self.auth = auth
self.country = country
self.config = config
# ------------------------------------------------------------------
# Routing
# ------------------------------------------------------------------
def handles_content_id(self, content_id: str) -> bool:
"""
True if this manager handles the given content_id.
Default: True. Override in providers whose VOD content_ids have a
distinguishable grammar (e.g. start with "details_" or "clip_").
"""
return True
# ------------------------------------------------------------------
# Abstract
# ------------------------------------------------------------------
@abstractmethod
def get_vod_category(
self,
content_id: str = "",
cursor: Optional[str] = None,
page_size: int = 24,
**kw: Any,
) -> VodPage:
"""Return children of a VOD node. Empty content_id is the root."""
raise NotImplementedError
@abstractmethod
def get_vod_manifest(
self, content_id: str, **kw: Any
) -> Optional[str]:
"""Return the manifest URL, or None if not handled by this manager."""
raise NotImplementedError
# ------------------------------------------------------------------
# Concrete
# ------------------------------------------------------------------
def search_vod(
self,
query: str,
cursor: Optional[str] = None,
page_size: int = 24,
**kw: Any,
) -> VodPage:
"""Search the catalogue. Default: no results. Override if supported."""
return VodPage()
def get_vod_manifest_headers(
self, content_id: str, **kw: Any
) -> Dict[str, str]:
"""Headers for the manifest request. Default: auth headers."""
return self.auth.build_headers()
def get_vod_drm(
self, content_id: str, **kw: Any
) -> List[DRMConfig]:
"""DRM for a VOD item. Default: no DRM."""
return []
+139
View File
@@ -0,0 +1,139 @@
# streaming_providers/base/protocols.py
"""
Structural typing protocols for provider collaborators.
These are NOT base classes. Providers match them by shape, not by
inheritance. Using them as type annotations gives IDE / mypy checking
without imposing a runtime hierarchy.
Why protocols and not ABCs
--------------------------
The Auth contract varies across providers -- RTL+ has three tokens,
Discovery has cookie-based session state, HRTi authorizes per-content
sessions. Forcing a single abstract base would require escape hatches.
A Protocol captures the *minimum shared shape* without forbidding
provider-specific extensions.
The manager ABCs (base/managers/*) are different: their interfaces are
actually shared, so they are ABCs. Only Auth, DRM, and the playback-
authorization step use protocols.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, runtime_checkable
if TYPE_CHECKING:
from .models import DRMConfig
@runtime_checkable
class AuthProtocol(Protocol):
"""
Minimal shared Auth interface.
Every provider's Auth class must provide these three methods. Additional
methods (get_scoped_token, get_session_context, authorize_playback, or
provider-specific helpers) are allowed but not required.
runtime_checkable means isinstance(obj, AuthProtocol) works at runtime,
checking only for method presence -- not signatures.
"""
def get_access_token(self, force_refresh: bool = False) -> str:
"""Return the raw token string (no scheme prefix)."""
...
def build_headers(
self, token: Optional[str] = None, **opts: Any
) -> Dict[str, str]:
"""
Return request-ready headers including auth.
If token is None, use the cached token (via get_access_token).
"""
...
def invalidate(self) -> None:
"""Drop cached token and session state. Called after a 401."""
...
@runtime_checkable
class PlaybackAuthorizationProtocol(Protocol):
"""
Optional protocol for the provider-specific pre-playback step.
Not every provider has this. Providers that do match this shape by
convention when they want to expose it as a stable entry point.
"""
def authorize_playback(
self, content_id: str, **opts: Any
) -> Dict[str, Any]:
"""Perform the provider's pre-playback authorization step."""
...
@runtime_checkable
class DrmManagerProtocol(Protocol):
"""
Shape for a provider's dedicated DRM manager (if it has one).
This is a protocol, not an ABC. Providers implement it however fits
their DRM source: as a dedicated DrmManager class, or folded into the
channel/vod managers. The protocol exists so callers and tests have a
name for the shape, and so type checkers can verify it if a provider
chooses to annotate.
Providers with no DRM do not implement this at all -- their
provider.get_drm() returns [] and implements_drm is False.
Two architectures are supported (see providers/_template/README.md,
section "DRM", for when to pick which):
* Dedicated manager: implement get_drm_configs() on a class
matching this protocol, and wire it in the
provider's _build_drm().
* Folded into managers: override get_channel_drm() on the
ChannelManager and/or get_vod_drm() on the
VodManager instead. The provider's
implements_drm flag is derived from whether
either override is present.
New providers should prefer the dedicated-manager shape unless the
DRM call shares significant state with the manifest step -- see the
README for the tradeoff.
Four existing patterns to model after (see
providers/_template/drm_manager.py for file-level references):
RTL+ per-content upfront token via lib_drmtoday.
Magenta licence URL constructed from token claims + /user/account.
Discovery DRM arrives with the playbackInfo response.
HRTi session id becomes a base64 auth blob via lib_drmtoday.
"""
def get_drm_configs(
self,
content_id: str,
content_type: Optional[str] = None,
**opts: Any,
) -> List["DRMConfig"]:
"""
Return the DRM configuration(s) for the given content.
content_type is an optional hint ("live" | "vod" | "event" |
"catchup"). When None, the manager infers the type from its own
content_id grammar -- which is the preferred mode, since the
manager knows its own grammar better than the caller does.
Callers that already know the type (e.g. the backend's streaming
route, which has already resolved the item) should pass it
explicitly so the manager skips the inference step.
Return [] when this provider has no DRM for the content. Auth /
geo / entitlement / rate-limit / server failures propagate as
exceptions from base.errors.
"""
...
+110
View File
@@ -0,0 +1,110 @@
# streaming_providers/base/vod.py
"""
Shared VOD return type.
Prior to this module, providers returned VOD category children in three
different shapes (dict-with-entries, bare list, ...). This module defines
one canonical shape.
Pagination rule: `next_cursor is None` is the authoritative end-of-list
signal (exposed as `has_more`). `total` may be missing; do not use it to
decide whether to keep paging.
Truthiness: bool(page) is driven by __len__, so an empty page is falsy and
a page with entries is truthy -- matching list semantics. An empty page
with a next_cursor is falsy; check `page.has_more` when the caller means
"are there more pages?".
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterator, List, Optional, Union
from .models.vod import VodCategory, VodItem
VodEntry = Union[VodCategory, VodItem]
@dataclass
class VodPage:
"""
One page of VOD results.
Attributes:
entries: Mixed list of VodCategory and VodItem.
next_cursor: Opaque continuation token; None = no next page.
total: Optional total count; None = unknown.
Truthiness follows list semantics via __len__: an empty page is falsy.
For pagination, use `has_more` -- NOT bool(page) -- because a page can
legitimately have zero entries and a non-None cursor.
"""
entries: List[VodEntry] = field(default_factory=list)
next_cursor: Optional[str] = None
total: Optional[int] = None
@property
def has_more(self) -> bool:
"""True when the provider indicated a next page exists.
This is the correct pagination check. `bool(page)` answers
"are there entries to display?" -- a different question.
"""
return self.next_cursor is not None
def __len__(self) -> int:
# Drives both len(page) and bool(page) via Python's default
# truthiness rule for objects with __len__. Matches list semantics.
return len(self.entries)
def __iter__(self) -> Iterator[VodEntry]:
return iter(self.entries)
def to_dict(self) -> dict:
return {
"entries": [
e.to_dict() if hasattr(e, "to_dict") else e
for e in self.entries
],
"next_cursor": self.next_cursor,
"total": self.total,
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def empty_page() -> VodPage:
"""Return a fresh empty VodPage with no pagination."""
return VodPage()
def normalize_vod_result(result) -> VodPage:
"""
Coerce a legacy return value into a VodPage.
Migration bridge. Once every provider returns VodPage directly, this
becomes redundant.
"""
if isinstance(result, VodPage):
return result
if result is None:
return VodPage()
if isinstance(result, dict):
return VodPage(
entries=result.get("entries") or [],
next_cursor=result.get("next_cursor"),
total=result.get("total"),
)
if isinstance(result, list):
return VodPage(entries=result)
import logging
logging.getLogger(__name__).warning(
f"normalize_vod_result: unexpected type {type(result).__name__}; "
f"returning empty page"
)
return VodPage()
@@ -0,0 +1,285 @@
# 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`.
- Token caching and session persistence (in your Auth class).
- Capability flags (`implements_vod`, `implements_epg`) — derived from
whether you wire up the corresponding manager.
- Shared error types, shared `VodPage` shape, shared `Channel` base.
## What you implement
1. **Auth** — `auth.py`. Writes the three shared methods and any optional
extensions the provider needs.
2. **Managers** — `channel_manager.py`, `vod_manager.py`, `epg_manager.py`.
Each subclasses the corresponding ABC from `base/managers/` and
implements the abstract methods.
3. **Provider wiring** — `provider.py`. Fills in `_build_*` factory
methods; returns `None` for capabilities the provider doesn't have.
4. **Constants** — `constants.py`. URLs, endpoints, static headers.
5. **Models** (optional) — `models.py`. Only if you need a custom Channel
or AuthToken subclass.
## The manager ABCs
Subclass `base.managers.ChannelManager`, `VodManager`, `EpgManager`. Each
has the same constructor contract and a small public interface.
**Constructor contract:**
def __init__(
self,
*,
http_manager,
auth,
country,
config,
your_extra_cache=None, # any extra keyword-only args you need
):
super().__init__(
http_manager=http_manager,
auth=auth,
country=country,
config=config,
)
self._your_extra_cache = your_extra_cache or {}
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.
## 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`.
**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 orchestrator's `get_manifest` / `get_drm` try managers in order. To
avoid a wasted request, override `handles_content_id()` on managers whose
content_ids have 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.isdigit()
If your provider has no content_id grammar, leave it returning `True` and
the router falls back to try-and-catch.
`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.
## The Auth protocol
Auth is a documented protocol (see `base/protocols.py`), not an ABC.
Every provider 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.
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.
## Conventions
- **Custom Channel / AuthToken subclasses are fine.** Call
`super().to_dict()` in your override. MoveTV, Discovery, and HRTi all
do this.
- **Provider owns caches; managers borrow them.** Create caches in the
provider's `__init__`; pass them into manager constructors.
- **Content ID grammar is provider-specific.** Pick one and document it
in the manager's docstring.
- **Playback authorization is provider-specific.** Don't force it into a
shared interface. See the five existing providers for five shapes.
## 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 pick ONE of two architectures:
**Architecture 1 — dedicated DRM manager (preferred for new providers)**
Create `drm_manager.py` with a class matching
`base.protocols.DrmManagerProtocol`. Wire it in the provider's
`_build_drm()` factory. The provider's `get_drm()` delegates to it.
When to pick this: DRM is a distinct step with its own data sources
(upfront tokens, licence URL construction, session authorization) that
does not share significant state with the manifest fetch.
See `drm_manager.py` in this directory for four concrete reference
patterns (RTL+ upfront token, Magenta constructed URL, Discovery
playbackInfo, HRTi session id). Pick the closest and adapt.
**Architecture 2 — folded into channel/vod managers**
Override `get_channel_drm()` on your `ChannelManager` and/or
`get_vod_drm()` on your `VodManager`. Leave `_build_drm()` returning
`None`. The provider's `get_drm()` routes through the manager list, and
`implements_drm` is derived from whether either override is present.
When to pick this: the DRM call shares state with the manifest fetch
(session ids, playbackInfo responses) and a separate manager would have
to be handed that state anyway. HRTi and Magenta use this shape.
**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` is a hint — pass it when you already know the
content type (e.g. the backend streaming route has already resolved the
item). Leave it `None` and the DRM source infers the type from its own
`content_id` grammar, which it knows better than the caller.
**Which architecture to pick — a rule of thumb**
Does the DRM call share state with the manifest fetch?
yes -> folded (Architecture 2)
no -> dedicated (Architecture 1)
Dedicated is preferred when both work, because it keeps the DRM logic in
one place. Folded is the right call when the alternative would be passing
session or playback state between two managers anyway.
## 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.
@@ -0,0 +1,15 @@
# providers/_template/__init__.py
"""
Template scaffold -- not a real provider.
This directory is a copy source for new providers, not a registrable
plugin. Its module does not import or export any StreamingProvider
subclass, so directory-based discovery (see streaming_providers/__init__.py)
never registers it, even if the leading-underscore skip rule is removed.
To use: copy this directory to providers/{new_name}/ and rename the
classes. Change this __init__.py to import and export YourProvider once
the new provider is a real one.
"""
__all__ = []
@@ -0,0 +1,220 @@
# streaming_providers/providers/_template/auth.py
"""
{TODO: Provider name} authentication.
Implements the three shared Auth methods (get_access_token, build_headers,
invalidate) and any optional extensions the provider needs.
Auth is a protocol, not an ABC. See base/protocols.py for the runtime
shape; see ../_template/README.md for the contract.
Constructor contract (recommended, not enforced):
__init__(*, http_manager, country, settings_manager=None,
credentials=None, **provider_opts)
The provider's _build_auth() factory calls this. Extra kwargs are for
provider-specific state (device_id, client_version, platform, ...).
"""
from typing import Any, Dict, Optional
from ...base.utils.logger import logger
class YourProviderAuth:
"""
Authenticator for {TODO: provider name}.
Not a subclass of any base class -- matches AuthProtocol by shape.
"""
def __init__(
self,
*,
http_manager,
country: str,
settings_manager=None,
credentials=None,
**provider_opts,
):
"""
Args:
http_manager: Shared HTTPManager instance (owned by provider).
country: Two-letter country code.
settings_manager: Base settings manager for credential storage.
May be None; the auth class must work without it.
credentials: Pre-supplied credentials (overrides storage).
**provider_opts: Provider-specific state (device_id,
client_version, platform, ...). Document what
you use; the base ignores everything here.
"""
self.http_manager = http_manager
self.country = country
self.settings_manager = settings_manager
self._credentials = credentials
self._cached_token = None
# TODO: store provider_opts you need, e.g.:
# self.device_id = provider_opts.get("device_id") or self._load_device_id()
# ------------------------------------------------------------------
# The three shared methods -- every provider implements these
# ------------------------------------------------------------------
def get_access_token(self, force_refresh: bool = False) -> str:
"""
Return the raw token string (no scheme prefix).
If a cached token exists and is not near expiry, return it. Otherwise
authenticate, cache, and return.
"""
if (
not force_refresh
and self._cached_token
and not self._cached_token.is_expired
):
return self._cached_token.access_token
token = self._perform_authentication()
self._cached_token = token
self._save_session(token)
return token.access_token
def build_headers(
self, token: Optional[str] = None, **opts
) -> Dict[str, str]:
"""
Return request-ready headers.
If token is None, fetch it via get_access_token(). Providers add
their own non-auth headers (device id, client version, session
state, origin, referer) here.
"""
if token is None:
token = self.get_access_token()
headers = {
"User-Agent": "TODO: your UA",
"Accept": "application/json",
# TODO: pick the auth scheme your provider uses:
# MoveTV "X-Auth-Token": token
# Magenta "Bff_token": token
# RTL+ "Authorization": f"Bearer {token}"
# HRTi "authorization": f"Client {token}"
# Discovery "Authorization": f"Bearer {token}" + session headers
"Authorization": f"Bearer {token}",
}
# TODO: add non-auth headers the API requires. Examples:
# "X-Device-Id": self.device_id
# "X-Client-Version": self.client_version
# "Origin": self.config.base_website
# "Referer": f"{self.config.base_website}/"
# Discovery-style session state, Magenta-style guest ids, etc. also
# go here (built from self._session_state or equivalent).
return headers
def invalidate(self) -> None:
"""
Drop cached token and session state. Called after 401s.
The next get_access_token() call must perform full re-authentication.
"""
self._cached_token = None
self._clear_session()
# TODO: clear provider-specific session state, e.g.:
# self._session_state = None
# self._disco_id = None
# self._cookies.clear()
# ------------------------------------------------------------------
# Provider-specific implementation
# ------------------------------------------------------------------
def _perform_authentication(self):
"""
Do the actual login HTTP call. Return a token object with at least
access_token, expires_in, and is_expired attributes.
Return your custom AuthToken subclass if you have one, otherwise
return a BaseAuthToken.
"""
# TODO:
# 1. Ensure credentials (self._credentials, else load from
# settings_manager).
# 2. Build the login payload (from .models.YourCredentials if you
# have a custom one, else the plain username/password dict).
# 3. POST to the login endpoint via self.http_manager.
# 4. Parse the response into your token class.
# 5. Return the token.
raise NotImplementedError(
"YourProviderAuth._perform_authentication"
)
def _save_session(self, token) -> None:
"""
Persist the token via settings_manager (optional).
Called after a successful authentication. If settings_manager is
None (e.g. in unit tests), do nothing.
"""
if self.settings_manager:
try:
self.settings_manager.save_token_data(
"TODO: provider_name",
token.to_dict(),
self.country,
)
except Exception as e:
logger.debug(f"Could not persist token: {e}")
def _clear_session(self) -> None:
"""Clear any persisted session data."""
if self.settings_manager:
try:
self.settings_manager.clear_token(
"TODO: provider_name", self.country
)
except Exception:
pass
# ------------------------------------------------------------------
# Optional extensions -- uncomment and implement only if needed
# ------------------------------------------------------------------
# def get_scoped_token(self, scope: str, **opts) -> Optional[str]:
# """
# Return a secondary token for the given scope, or None.
#
# RTL+ uses this for "bedrock" and "upfront" tokens. Providers
# without secondary tokens should leave this uncommented-out and
# returning None, or simply not define it at all (the base protocol
# only requires the three shared methods).
# """
# return None
# def get_session_context(self) -> Optional[Dict[str, Any]]:
# """
# Return opaque session state needed by build_headers.
#
# Magenta returns {"device_id": ..., "session_id": ...} from this.
# Discovery returns the current session headers. Providers without
# session state leave this returning None.
# """
# return None
# def authorize_playback(
# self, content_id: str, **opts
# ) -> Dict[str, Any]:
# """
# Provider-specific pre-playback step.
#
# HRTi's AuthorizeSession, MoveTV's live-source fetch, Discovery's
# playbackInfo POST, RTL+'s upfront token, Magenta's persona JWT
# retrieval. Return whatever your channel/vod managers need
# downstream.
#
# There is no fixed interface for this. The name is a convention;
# the shape is provider-specific.
# """
# return {}
@@ -0,0 +1,55 @@
# streaming_providers/providers/_template/channel_manager.py
"""
{TODO: Provider name} channel manager.
Subclasses base.managers.ChannelManager. See ../_template/README.md.
"""
from typing import Any, Dict, List, Optional
from ...base.managers import ChannelManager
from ...base.models import Channel, DRMConfig
from ...base.utils.logger import logger
class YourChannelManager(ChannelManager):
"""Fetches live channels for {TODO: provider name}."""
def __init__(
self,
*,
http_manager,
auth,
country,
config,
channels_cache: Optional[Dict] = None,
):
# Forward ONLY the four required collaborators. Extra state goes
# on self below. A typo at the call site (e.g. channel_cache=)
# raises TypeError immediately.
super().__init__(
http_manager=http_manager,
auth=auth,
country=country,
config=config,
)
self._channels_cache = (
channels_cache if channels_cache is not None else {}
)
# ----- Abstract methods -----
def get_channels(self, **kw) -> List[Channel]:
raise NotImplementedError("YourChannelManager.get_channels")
def get_channel_manifest(
self, content_id: str, **kw
) -> Optional[str]:
raise NotImplementedError(
"YourChannelManager.get_channel_manifest"
)
# ----- Optional overrides -----
# def handles_content_id(self, content_id: str) -> bool:
# return content_id.isdigit()
@@ -0,0 +1,62 @@
# streaming_providers/providers/_template/constants.py
"""
{TODO: Provider name} constants.
All URLs, endpoint paths, static header values, and default parameters
live here so no other file contains magic strings.
Structure:
* YourDefaults -- class-level constants.
* YourConfig -- instance config with override support, plus header
and URL builder methods.
"""
class YourDefaults:
PROVIDER_NAME = "TODO"
PROVIDER_LOGO = "TODO: url"
BASE_URL = "TODO: https://..."
WEBSITE = "TODO: https://..."
PATH_LOGIN = "/api/login"
PATH_CHANNELS = "/api/channels"
# ... etc
USER_AGENT = "TODO"
TIMEOUT = 30
# Static values the API expects (partner ids, client versions, ...).
class YourConfig:
"""
Per-instance configuration.
Attributes the template's provider.py relies on:
user_agent -- string, passed to _setup_http_manager.
timeout -- int seconds, passed to _setup_http_manager.
"""
def __init__(self, config_dict: dict = None):
config = config_dict or {}
self.base_url = config.get("base_url", YourDefaults.BASE_URL)
self.user_agent = config.get("user_agent", YourDefaults.USER_AGENT)
self.timeout = config.get("timeout", YourDefaults.TIMEOUT)
# ... any other provider-specific config fields
# ----- Header builders -----
def get_base_headers(self) -> dict:
return {
"User-Agent": self.user_agent,
"Accept": "application/json",
}
# ----- URL builders -----
def login_url(self) -> str:
return f"{self.base_url}{YourDefaults.PATH_LOGIN}"
def channels_url(self) -> str:
return f"{self.base_url}{YourDefaults.PATH_CHANNELS}"
@@ -0,0 +1,152 @@
# streaming_providers/providers/_template/drm_manager.py
"""
{TODO: Provider name} DRM manager (optional).
Include this file only if the provider has DRM and you are using the
dedicated-manager architecture. Providers that fold DRM into their
channel/vod managers do not need this file -- see the README section
"DRM" for how the folded architecture is wired.
There is no DRM base class, only a protocol (base/protocols.py,
DrmManagerProtocol). The shape is shared; the implementations vary enough
that a shared ABC would need more escape hatches than it saves. This file
is a scaffold and a document -- not an abstract class.
How to structure a DRM manager for a new provider
-------------------------------------------------
1. Class shape
class YourDrmManager:
def __init__(
self,
*,
http_manager, # shared HTTPManager from the provider
auth, # your Auth instance (matches AuthProtocol)
country,
config,
# plus whatever else this provider's DRM needs:
# playback_manager=None, session_cache=None, ...
):
...
def get_drm_configs(
self,
content_id: str,
content_type: Optional[str] = None,
**opts,
) -> List[DRMConfig]:
# If content_type is None, infer it from content_id grammar.
...
2. Wiring
In provider.py's __init__:
self.drm = self._build_drm()
And:
def _build_drm(self) -> Optional[DrmManagerProtocol]:
return YourDrmManager(...)
Return None from _build_drm() for providers with no DRM.
The provider's implements_drm property is derived (see the template's
provider.py). implements_drm is True when either a dedicated DRM
manager is present, or the channel/vod managers override their DRM
methods.
The provider's get_drm() delegates to this manager when present.
3. Reference implementations
The four existing patterns differ enough that picking the closest
match and adapting it is faster than designing from scratch. Read
the referenced files before writing yours; the description below
is a summary, the code is the source of truth.
Pattern A -- per-content upfront token
Files: providers/rtlplus/provider.py (DRM flow)
providers/rtlplus/auth.py (upfront token)
providers/lib_drmtoday.py (the shared library)
Summary: fetch the layout for the content_id, extract the DRM
asset config, call auth.get_scoped_token("upfront", content_id,
uid), then lib_drmtoday.create_drmtoday_configs(...). Returns
[widevine, playready] in one call.
Requires an authenticated user (profile selected). The upfront
token is per-content, not per-session.
Pattern B -- construct licence URL from token claims
Files: providers/magentaeu/vod_manager.py (DRM flow)
providers/magentaeu/provider.py (live DRM flow)
providers/magentaeu/auth.py (token claims)
providers/lib_theplatform.py (URL + config builders)
Summary: resolve the media item to get release_pid (via the
/media endpoint), read persona_jwt from the access token claims,
read account_uri from /user/account, then
lib_theplatform.build_licence_url(...) and
lib_theplatform.build_widevine_drm_config(...). Returns
[widevine].
The licence URL is CONSTRUCTED, not fetched -- three data
sources: /media response, token claims, /user/account.
Pattern C -- DRM arrives with the playback response
Files: providers/discovery/playback_manager.py (playbackInfo +
DRM extraction in one place)
providers/discovery/constants.py (platform_os -> DRM
system mapping)
Summary: during get_manifest, POST playbackInfo and cache the
response. get_drm() is a cache lookup; no separate DRM call.
The response carries both widevine and playready schemes with
licenseUrl each; pick the one matching the active platform_os.
Returns [widevine] or [playready].
DRM and manifest share the playbackInfo cache. Do NOT re-fetch.
drm.expirationDate governs cache invalidation.
Pattern D -- session id becomes a base64 auth blob
Files: providers/hrti/provider.py (both live and VOD DRM flows)
providers/hrti/auth.py (session authorize + licence
data generation)
providers/lib_drmtoday.py (the shared library)
Summary: auth.authorize_session(...) returns a session dict
carrying "DrmId". auth.get_license_data(DrmId) returns a
base64-encoded JSON blob. Then
lib_drmtoday.create_drmtoday_widevine_config(..., auth_header_name=
"dt-custom-data"). Returns [widevine].
authorize_session must run before get_drm; the provider caches
the session so the manifest step and the DRM step share it. The
base64 blob is passed as a header, not a query param.
4. What to cache and where
Every existing provider caches DRM material somewhere:
* RTL+ no cache; the upfront token is refetched per playback.
* Magenta the account_info lives on the auth token, not the
DRM manager.
* Discovery the whole playbackInfo response lives in the
playback_cache, keyed by edit_id, TTL from
drm.expirationDate.
* HRTi the session dict lives in a session cache, populated
during the manifest step.
Pick whichever fits. If DRM shares state with manifest, put the cache
on the provider and pass it into both managers, matching the pattern
the other managers already use.
5. Testing
DRM managers take http_manager and auth by injection, so they are
testable without network access. Provide a mock http_manager that
returns canned licence responses, and a mock auth that returns a
canned token with the right claims. No real DRM call needed.
6. Fields on DRMConfig
If your provider returns something not covered by the existing
DRMConfig fields, do NOT extend DRMConfig. Instead:
* If it's a header, add it to license.req_headers.
* If it's a request body format, use license.req_data.
* If it's something structurally new, raise it before extending the
shared model -- every provider inherits changes.
"""
@@ -0,0 +1,46 @@
# streaming_providers/providers/_template/epg_manager.py
"""
{TODO: Provider name} EPG manager.
Subclasses base.managers.EpgManager. See ../_template/README.md.
"""
from datetime import datetime
from typing import Dict, List, Optional, Tuple
from ...base.managers import EpgManager
from ...base.models.epg_models import EPGEntry, EPGProgramDetails
from ...base.utils.logger import logger
class YourEpgManager(EpgManager):
"""EPG for {TODO: provider name}."""
@property
def epg_window(self) -> Tuple[int, int]:
"""(past_days, future_days). (0, 0) means no EPG support."""
return 0, 0 # TODO: e.g. (2, 7)
def get_epg(
self,
channel_id: str,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
**kw,
) -> List[EPGEntry]:
raise NotImplementedError("YourEpgManager.get_epg")
# ----- Optional overrides -----
# def get_epg_grid(
# self,
# channel_ids: List[str],
# start_time: Optional[datetime] = None,
# end_time: Optional[datetime] = None,
# **kw,
# ) -> Dict[str, List[EPGEntry]]:
# # Override if the provider has a native batch endpoint.
# return super().get_epg_grid(
# channel_ids, start_time=start_time,
# end_time=end_time, **kw
# )
@@ -0,0 +1,146 @@
# streaming_providers/providers/_template/models.py
"""
{TODO: Provider name} models.
Only needed if your provider requires:
* A custom Channel subclass (extra fields on channels — see MoveTV's
MoveTVChannel and Discovery's DiscoveryChannel).
* A custom AuthToken subclass (extra claims on the token — most existing
providers have one: RTLPlusAuthToken, MagentaAuthToken, MoveTVAuthToken,
DiscoveryAuthToken, HRTiAuthToken).
* A custom Credentials subclass (unusual auth payload — see HRTi's
HRTiCredentials).
If your provider can be expressed with the base Channel / BaseAuthToken and
a plain UserPasswordCredentials, you don't need this file.
Rules
-----
* When overriding to_dict(), call super().to_dict() and add your fields.
Both Channel.to_dict() and BaseAuthToken.to_dict() chain correctly.
* Custom Channel subclasses are returned from ChannelManager.get_channels()
as-is; nothing in the base inspects the concrete type.
* Custom AuthToken subclasses are returned from your Auth's
_perform_authentication(); the base never inspects their type beyond the
attributes it needs (access_token, expires_in, is_expired).
"""
# ---------------------------------------------------------------------------
# Example: custom Channel subclass
# ---------------------------------------------------------------------------
# from dataclasses import dataclass
# from typing import Any, Dict
#
# from ...base.models import Channel
#
#
# @dataclass
# class YourChannel(Channel):
# """
# Channel with provider-specific extra fields.
#
# Keep the base class's field names and defaults; add new fields after
# them so positional construction still works if any caller relies on it.
# Keyword construction is preferred.
# """
#
# # Provider-specific extras.
# your_field: str = ""
# your_expires_at: float = 0.0
#
# def to_dict(self) -> Dict[str, Any]:
# result = super().to_dict()
# result["YourField"] = self.your_field
# result["YourExpiresAt"] = self.your_expires_at
# return result
# ---------------------------------------------------------------------------
# Example: custom AuthToken subclass
# ---------------------------------------------------------------------------
# from typing import Any, Dict, Optional
#
# from ...base.auth.base_auth import BaseAuthToken
#
#
# class YourAuthToken(BaseAuthToken):
# """
# AuthToken with provider-specific fields.
#
# BaseAuthToken.__init__ takes:
# access_token, token_type, expires_in, issued_at,
# refresh_token=None, refresh_expires_in=0
#
# Add your fields as keyword args with sensible defaults.
# """
#
# def __init__(
# self,
# *,
# access_token: str,
# token_type: str,
# expires_in: int,
# issued_at: float,
# your_extra: str = "",
# refresh_token: Optional[str] = None,
# refresh_expires_in: int = 0,
# ):
# super().__init__(
# access_token=access_token,
# token_type=token_type,
# expires_in=expires_in,
# issued_at=issued_at,
# refresh_token=refresh_token,
# refresh_expires_in=refresh_expires_in,
# )
# self.your_extra = your_extra
#
# def to_dict(self) -> Dict[str, Any]:
# result = super().to_dict()
# result["your_extra"] = self.your_extra
# return result
#
# @classmethod
# def from_dict(cls, data: Dict[str, Any]) -> "YourAuthToken":
# """Reconstruct from a persisted dict. Used by _load_session()."""
# return cls(
# access_token=data["access_token"],
# token_type=data.get("token_type", "Bearer"),
# expires_in=data.get("expires_in", 0),
# issued_at=data.get("issued_at", 0),
# your_extra=data.get("your_extra", ""),
# refresh_token=data.get("refresh_token"),
# refresh_expires_in=data.get("refresh_expires_in", 0),
# )
# ---------------------------------------------------------------------------
# Example: custom Credentials subclass
# ---------------------------------------------------------------------------
# from dataclasses import dataclass
# from typing import Any, Dict
#
# from ...base.auth.credentials import UserPasswordCredentials
#
#
# @dataclass
# class YourCredentials(UserPasswordCredentials):
# """
# Credentials with a provider-specific payload shape.
#
# Only needed when the provider's login payload isn't the usual
# {username, password} shape (HRTi's grant_access takes
# {Username, Password, OperatorReferenceId}, for example).
# """
#
# operator_reference_id: str = "default"
#
# def to_auth_payload(self) -> Dict[str, Any]:
# return {
# "Username": self.username,
# "Password": self.password,
# "OperatorReferenceId": self.operator_reference_id,
# }
@@ -0,0 +1,249 @@
# streaming_providers/providers/_template/provider.py
"""
{TODO: Provider name} orchestrator.
Owns shared resources (http_manager, caches, auth, managers) and exposes
the public StreamingProvider interface.
Subclasses the existing StreamingProvider. Does NOT subclass any new
base class. Authentication is lazy -- no network I/O in __init__.
"""
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
from ...base.errors import NotFoundError
from ...base.managers import ChannelManager, VodManager
from ...base.models.proxy_models import ProxyConfig
from ...base.protocols import DrmManagerProtocol
from ...base.provider import StreamingProvider
from ...base.utils.logger import logger
from .auth import YourProviderAuth
from .channel_manager import YourChannelManager
from .constants import YourConfig
# from .vod_manager import YourVodManager
# from .epg_manager import YourEpgManager
# from .drm_manager import YourDrmManager
class YourProvider(StreamingProvider):
"""{TODO: provider name} streaming provider."""
PROVIDER_LABEL: ClassVar[str] = "TODO: display label"
PROVIDER_LOGO: ClassVar[str] = "TODO: logo url"
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["user_credentials"]
SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["TODO", "country", "codes"]
def __init__(
self,
country: str = "TODO",
config: Optional[Dict] = None,
proxy_config: Optional[ProxyConfig] = None,
settings_manager=None,
**kwargs,
):
super().__init__(country)
config = config or {}
self.config = YourConfig(config)
# 1. HTTP manager.
self.http_manager = self._setup_http_manager(
provider_name="TODO: provider_name",
proxy_config=proxy_config,
user_agent=self.config.user_agent,
timeout=self.config.timeout,
)
# 2. Auth (protocol, not ABC). Lazy -- no network call here.
self.auth = self._build_auth(settings_manager)
# 3. Provider-owned caches. Managers borrow these by reference.
self._channels_cache: Dict = {}
self._playback_cache: Dict = {}
# 4. Managers.
self.channels = self._build_channels()
self.vod = self._build_vod()
self.epg = self._build_epg()
self.drm = self._build_drm()
# ----- Factory methods -----
def _build_auth(self, settings_manager):
return YourProviderAuth(
http_manager=self.http_manager,
country=self.country,
settings_manager=settings_manager,
)
def _build_channels(self):
return YourChannelManager(
http_manager=self.http_manager,
auth=self.auth,
country=self.country,
config=self.config,
channels_cache=self._channels_cache,
)
def _build_vod(self):
# TODO: return YourVodManager(
# http_manager=self.http_manager,
# auth=self.auth,
# country=self.country,
# config=self.config,
# playback_cache=self._playback_cache,
# )
return None
def _build_epg(self):
return None
def _build_drm(self) -> Optional[DrmManagerProtocol]:
"""
Return a dedicated DRM manager, or None.
Two supported architectures:
* Dedicated manager: return a class matching DrmManagerProtocol
here; the provider's get_drm() delegates to it.
* Folded into managers: leave this returning None, and instead
override get_channel_drm() on your ChannelManager and/or
get_vod_drm() on your VodManager. The provider's get_drm()
falls back to routing to those.
New providers should prefer the dedicated manager (see the README
section "DRM"). The folded style exists for providers whose DRM
call shares significant state with the manifest step.
See providers/_template/drm_manager.py for the four existing
patterns (RTL+ upfront token, Magenta constructed URL, Discovery
playbackInfo, HRTi session id).
"""
# TODO: return YourDrmManager(
# http_manager=self.http_manager,
# auth=self.auth,
# country=self.country,
# config=self.config,
# # ... provider-specific collaborators
# )
return None
# ----- Capability flags (derived from manager presence) -----
@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_drm(self) -> bool:
"""
True when this provider can produce DRM configurations.
Derived from either source of DRM:
* a dedicated DRM manager (_build_drm returned a class), OR
* a ChannelManager that overrides get_channel_drm, OR
* a VodManager that overrides get_vod_drm.
The base classes' defaults return []; we detect overrides by
comparing the bound method against the base class's method. This
is what makes the flag correct for the folded architecture.
"""
if self.drm is not None:
return True
folded_channels = (
self.channels is not None
and type(self.channels).get_channel_drm
is not ChannelManager.get_channel_drm
)
folded_vod = (
self.vod is not None
and type(self.vod).get_vod_drm
is not VodManager.get_vod_drm
)
return folded_channels or folded_vod
# ----- Router -----
def _route(self, content_id: str, attempts: List[Tuple[Any, Callable]]):
"""
Try managers in order, using handles_content_id() to skip those
that declare they don't handle the id.
Distinguishes three outcomes:
* manager returned a truthy result -> return it
* manager returned None / [] / falsy -> try next manager
* manager raised NotFoundError -> remember it, try next
If nobody resolved and a NotFoundError was seen, re-raise it --
that's "the content existed in some manager's domain but is gone",
distinct from "nobody handles this id at all" (which returns None).
"""
last_not_found: Optional[NotFoundError] = None
for manager, call in attempts:
if manager is None or not manager.handles_content_id(content_id):
continue
try:
result = call(manager)
except NotFoundError as e:
last_not_found = e
continue
if result:
return result
if last_not_found is not None:
raise last_not_found
return None
# ----- Public delegations -----
def get_channels(self, **kw):
if self.channels is None:
return []
return self.channels.get_channels(**kw)
def get_manifest(self, content_id: str, **kw) -> Optional[str]:
return self._route(content_id, [
(self.channels, lambda m: m.get_channel_manifest(content_id, **kw)),
(self.vod, lambda m: m.get_vod_manifest(content_id, **kw)),
])
def get_drm(
self,
content_id: str,
content_type: Optional[str] = None,
**kw,
) -> List:
"""
Return DRM configuration(s) for the given content.
content_type is an optional hint. When None (the default), the
DRM source infers the type from its own content_id grammar --
which is the preferred mode, since the source knows its own
grammar better than the caller does. Callers that already know
the type (e.g. the backend's streaming route) should pass it
explicitly.
If a dedicated DRM manager is configured, delegate to it. Otherwise
fall back to per-manager DRM (channel manager's get_channel_drm,
VOD manager's get_vod_drm) via the router.
"""
if self.drm is not None:
return self.drm.get_drm_configs(
content_id,
content_type=content_type,
**kw,
)
return self._route(content_id, [
(self.channels, lambda m: m.get_channel_drm(content_id, **kw)),
(self.vod, lambda m: m.get_vod_drm(content_id, **kw)),
]) or []
@@ -0,0 +1,67 @@
# streaming_providers/providers/_template/vod_manager.py
"""
{TODO: Provider name} VOD manager.
Subclasses base.managers.VodManager. Document the content_id grammar here.
See ../_template/README.md for the contract.
"""
from typing import Any, Dict, Optional
from ...base.managers import VodManager
from ...base.models import DRMConfig
from ...base.vod import VodPage
from ...base.utils.logger import logger
class YourVodManager(VodManager):
"""
VOD catalogue navigation for {TODO: provider name}.
content_id grammar (TODO: fill in):
"" root
"folder_<id>" browse a folder
"details_<id>" a single item
"""
def __init__(
self,
*,
http_manager,
auth,
country,
config,
playback_cache: Optional[Dict] = None,
):
super().__init__(
http_manager=http_manager,
auth=auth,
country=country,
config=config,
)
self._playback_cache = (
playback_cache if playback_cache is not None else {}
)
# ----- Abstract methods -----
def get_vod_category(
self,
content_id: str = "",
cursor: Optional[str] = None,
page_size: int = 24,
**kw,
) -> VodPage:
raise NotImplementedError("YourVodManager.get_vod_category")
def get_vod_manifest(
self, content_id: str, **kw
) -> Optional[str]:
raise NotImplementedError("YourVodManager.get_vod_manifest")
# ----- Optional overrides -----
# def handles_content_id(self, content_id: str) -> bool:
# return content_id.startswith(("details_", "clip_"))