Update template

This commit is contained in:
Nirvana
2026-10-04 16:41:49 +02:00
parent a69a884f19
commit 17fc7acece
14 changed files with 768 additions and 279 deletions
@@ -34,6 +34,23 @@ and fill in the stubs. Read this file first — it explains the contract.
`BaseAuthToken` is an ABC with an abstract `to_dict()`. A custom
Channel subclass is optional. See "Models" below.
## Files in this template
provider.py orchestrator (required)
auth.py token + credential surface (if the provider authenticates)
models.py AuthToken subclass (mandatory with auth); Channel/Credentials examples
constants.py URLs, paths, headers, parameter names, PROVIDER_NAME
channel_manager.py ChannelManager + module-scope content-id parsers
vod_manager.py VodManager
epg_manager.py EpgManager
recordings_manager.py RecordingsManager
favorites_manager.py FavoritesManager
bookmarks_manager.py BookmarksManager
catchup_manager.py CatchupManager
drm_manager.py dedicated DRM manager (a protocol, not an ABC) + patterns
Delete the files for capabilities the provider does not have.
## Provider class members
Every provider declares these. Some are abstract (must be implemented by
@@ -647,7 +664,7 @@ 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 thesame prefix means two code paths for the same content, and they will
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
@@ -904,6 +921,11 @@ 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
@@ -1155,7 +1177,9 @@ 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`.
completed. Deleting a non-existent bookmark raises `KeyError`. Backend
failures raise a `ProviderError` subclass from `base.errors`, like every
other manager.
## Conventions
@@ -8,8 +8,15 @@ 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.
classes. Then replace the body of this __init__.py with:
from .provider import YourProvider # renamed
__all__ = ["YourProvider"]
The registry derives the plugin name from the class name
(`cls.__name__.lower().replace("provider", "")`), so the class name must
match the directory name you chose.
"""
__all__ = []
@@ -3,23 +3,53 @@
{TODO: Provider name} authentication.
Implements the three shared Auth methods (get_access_token, build_headers,
invalidate) and any optional extensions the provider needs.
invalidate), the credential surface (has_credentials, set_credentials,
clear_credentials), 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.
shape; see ../_template/README.md ("The Auth protocol") 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, ...).
Credentials source priority (README, "Credentials"):
1. constructor argument (CLI, tests)
2. injected settings_manager (may be None -- the registry usually
constructs providers WITHOUT one)
3. CredentialManager (direct credentials.json read) -- the path that
actually works in the normal runtime
4. fallback credentials (anonymous/free tier), if any
Credentials are re-read on every authenticate, so a user who stores them
after the provider was constructed does not need an app restart.
Deviations to document HERE (module docstring) if your provider has them:
* Token NOT in a header (query param / body field): build_headers()
returns base headers only; callers attach the token via
with_token(url, param=...) / auth_body(). Put the param names in
constants.py (they can differ per endpoint) and add a one-line
comment at every manager call site that uses build_headers().
* Content-Type quirks (e.g. a login endpoint that needs text/plain with
a JSON body): send data=json.dumps(payload) with the header set for
THAT call only, and comment why, or someone will "fix" it.
Thread safety: the host is multi-threaded. get_access_token() and the
other stateful accessors hold an RLock so two threads on a cold cache do
not run the (multi-step) login twice. Providers whose login is a single
HTTP call can drop the lock.
"""
import threading
from typing import Any, Dict, Optional
from ...base.auth.credential_manager import CredentialManager
from ...base.auth.credentials import UserPasswordCredentials
from ...base.errors import CredentialsError
from ...base.utils.logger import logger
from .constants import YourDefaults
from .models import YourAuthToken
class YourProviderAuth:
"""
@@ -35,15 +65,17 @@ class YourProviderAuth:
country: str,
settings_manager=None,
credentials=None,
config=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).
settings_manager: Base settings manager. May be None; the auth
class must work without it.
credentials: Pre-supplied credentials (source #1).
config: The provider's YourConfig (URLs, base headers).
**provider_opts: Provider-specific state (device_id,
client_version, platform, ...). Document what
you use; the base ignores everything here.
@@ -51,11 +83,15 @@ class YourProviderAuth:
self.http_manager = http_manager
self.country = country
self.settings_manager = settings_manager
self.config = config
self._credentials = credentials
self._cached_token = None
self._lock = threading.RLock()
# TODO: store provider_opts you need, e.g.:
# self.device_id = provider_opts.get("device_id") or self._load_device_id()
# Optional persistence: restore a stored token (no network I/O).
self._cached_token = self._load_session()
# ------------------------------------------------------------------
# The three shared methods -- every provider implements these
# ------------------------------------------------------------------
@@ -64,20 +100,21 @@ class YourProviderAuth:
"""
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 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
with self._lock:
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
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
@@ -92,25 +129,26 @@ class YourProviderAuth:
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}",
}
headers = (
self.config.get_base_headers()
if self.config is not None
else {"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
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}/"
# "Origin": ..., "Referer": ...
# Discovery-style session state, Magenta-style guest ids, etc. also
# go here (built from self._session_state or equivalent).
# go here.
return headers
@@ -118,88 +156,216 @@ class YourProviderAuth:
"""
Drop cached token and session state. Called after 401s.
The next get_access_token() call must perform full re-authentication.
The next get_access_token() call must perform full
re-authentication. Callers: on a 401, call invalidate() and retry
the request once -- never in a loop.
"""
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()
with self._lock:
self._cached_token = None
self._clear_session()
# TODO: clear provider-specific session state, e.g.:
# self._session_state = None
# self._cookies.clear()
# ------------------------------------------------------------------
# Credential surface (providers with user credentials)
#
# Providers WITHOUT user credentials: has_credentials() returns True,
# set_/clear_credentials() are no-ops returning False, and
# _ensure_credentials() is not needed in _perform_authentication().
# ------------------------------------------------------------------
def has_credentials(self) -> bool:
"""True if this auth can authenticate right now."""
if self._credentials and self._credentials.validate():
return True
fresh = self._load_stored_credentials()
if fresh and fresh.validate():
return True
fallback = self.get_fallback_credentials()
return bool(fallback and fallback.validate())
def set_credentials(self, username: str, password: str) -> bool:
"""Persist credentials (called by the settings UI)."""
if not self.settings_manager:
return False
try:
self.settings_manager.save_provider_credentials(
YourDefaults.PROVIDER_NAME,
UserPasswordCredentials(username, password),
self.country,
)
except Exception as e:
logger.warning(f"Could not store credentials: {e}")
return False
with self._lock:
self._credentials = None # force a re-read on next login
self.invalidate()
return True
def clear_credentials(self) -> bool:
"""Clear stored credentials and drop the cached token."""
try:
if self.settings_manager:
self.settings_manager.clear_provider_credentials(
YourDefaults.PROVIDER_NAME, self.country
)
else:
CredentialManager().delete_credentials(
YourDefaults.PROVIDER_NAME, self.country
)
except Exception as e:
logger.warning(f"Could not clear credentials: {e}")
return False
with self._lock:
self._credentials = None
self.invalidate()
return True
def get_fallback_credentials(self):
"""
Credentials for an anonymous / limited free tier, or None.
Override for providers that work without user configuration.
"""
return None
def _load_stored_credentials(self):
"""Sources #2 and #3: settings_manager first, then CredentialManager."""
if self.settings_manager and hasattr(
self.settings_manager, "get_provider_credentials"
):
try:
creds = self.settings_manager.get_provider_credentials(
YourDefaults.PROVIDER_NAME, self.country
)
if creds:
return creds
except Exception as e:
logger.debug(f"settings_manager credentials failed: {e}")
try:
# Covers both the country-nested and flat storage layouts.
return CredentialManager().load_credentials(
YourDefaults.PROVIDER_NAME, self.country
)
except Exception as e:
logger.debug(f"CredentialManager load failed: {e}")
return None
def _ensure_credentials(self) -> bool:
"""
Make self._credentials valid, re-reading storage if needed.
Call at the START of _perform_authentication(). Without it the
auth class silently depends on the caller having passed
credentials at construction -- which the registry never does.
"""
if self._credentials and self._credentials.validate():
return True
fresh = self._load_stored_credentials()
if fresh and fresh.validate():
self._credentials = fresh
return True
self._credentials = self.get_fallback_credentials()
return self._credentials is not None and self._credentials.validate()
# ------------------------------------------------------------------
# Provider-specific implementation
# ------------------------------------------------------------------
def _perform_authentication(self):
def _perform_authentication(self) -> YourAuthToken:
"""
Do the actual login HTTP call. Return a token object with at least
access_token, expires_in, and is_expired attributes.
Do the actual login HTTP call and return a YourAuthToken.
Return your custom AuthToken subclass if you have one, otherwise
return a BaseAuthToken.
(A concrete AuthToken subclass is mandatory: BaseAuthToken is an
ABC. See models.py.)
"""
# 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.
if not self._ensure_credentials():
raise CredentialsError(
f"no credentials available for {YourDefaults.PROVIDER_NAME}"
)
payload = self._build_login_payload(self._credentials)
resp = self.http_manager.post(
self._login_url(), json=payload, headers=self._login_headers()
)
return self._create_token_from_response(resp.json())
def _login_url(self) -> str:
return self.config.login_url()
def _login_headers(self) -> Dict[str, str]:
# Base headers only: build_headers() would try to fetch a token.
return self.config.get_base_headers()
def _build_login_payload(self, credentials) -> Dict[str, Any]:
# Custom credentials classes provide to_auth_payload().
# TODO: adapt to your provider's login payload.
return {
"username": credentials.username,
"password": credentials.password,
}
def _create_token_from_response(self, data: Dict[str, Any]) -> YourAuthToken:
# TODO: parse the login response. Check base/auth/base_auth.py for
# any additional required BaseAuthToken fields.
raise NotImplementedError(
"YourProviderAuth._perform_authentication"
"YourProviderAuth._create_token_from_response"
)
def _save_session(self, token) -> None:
"""
Persist the token via settings_manager (optional).
# ------------------------------------------------------------------
# Session persistence (OPTIONAL)
#
# Skip it for providers with cheap re-auth (opaque token, no refresh
# flow -- simpliTV does). Keep it for expensive flows (multi-step,
# rate-limited, device codes). If you skip it, delete _load_session /
# _save_session / _clear_session and the call in __init__.
# ------------------------------------------------------------------
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 _load_session(self) -> Optional[YourAuthToken]:
if not self.settings_manager:
return None
try:
stored = self.settings_manager.load_token_data(
YourDefaults.PROVIDER_NAME, self.country
)
return YourAuthToken.from_dict(stored) if stored else None
except Exception as e:
logger.debug(f"Could not restore stored token: {e}")
return None
def _save_session(self, token) -> None:
if not self.settings_manager:
return
try:
self.settings_manager.save_token_data(
YourDefaults.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
if not self.settings_manager:
return
try:
self.settings_manager.clear_token(
YourDefaults.PROVIDER_NAME, self.country
)
except Exception as e:
logger.debug(f"Could not clear stored token: {e}")
# ------------------------------------------------------------------
# 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).
# """
# """Secondary token for the given scope (RTL+: bedrock / upfront)."""
# 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.
# Opaque session state needed by build_headers. Magenta returns
# {"device_id": ..., "session_id": ...}; Discovery the current
# session headers.
# """
# return None
@@ -207,14 +373,14 @@ class YourProviderAuth:
# 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.
# Provider-specific pre-playback step (HRTi AuthorizeSession,
# MoveTV live-source fetch, Discovery playbackInfo POST, RTL+
# upfront token, Magenta persona JWT). No fixed interface; the
# name is a convention, the shape is provider-specific.
# """
# return {}
# return {}
# def with_token(self, url: str, param: Optional[str] = None) -> str:
# """Token-in-URL providers: append the token. Take the parameter
# name from constants.py (YourDefaults.TOKEN_PARAM), never hardcode."""
# ...
@@ -23,9 +23,17 @@ 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.
Errors
------
Backend failures raise a ProviderError subclass from base.errors
(ServerError, TransportError, ...), per the README's "Errors" section.
NOTE: earlier revisions of this template said RuntimeError. If
ProviderBookmarksMixin still documents RuntimeError, align the two
(callers catching RuntimeError would miss ProviderError).
"""
from typing import Any, List, Optional
from typing import List, Optional
from ...base.managers import BookmarksManager
from ...base.models.bookmark import Bookmark, ContentType
@@ -74,7 +82,7 @@ class YourBookmarksManager(BookmarksManager):
position_seconds = -1 marks the content as completed.
Raises RuntimeError if the provider rejects.
Raises a ProviderError subclass if the provider rejects.
"""
raise NotImplementedError("YourBookmarksManager.update_bookmark")
@@ -83,7 +91,7 @@ class YourBookmarksManager(BookmarksManager):
Delete a bookmark.
Raises:
KeyError: if no bookmark exists for content_id.
RuntimeError: on backend failure.
KeyError: if no bookmark exists for content_id.
ProviderError: (a subclass) on backend failure.
"""
raise NotImplementedError("YourBookmarksManager.delete_bookmark")
@@ -22,6 +22,11 @@ Magenta EU's provider (providers/magentaeu/provider.py) implements
catchup by appending start/end query parameters to the live manifest
URL via build_catchup_url().
simpliTV's catchup only takes a start bound: it passes end_time=None
through and documents that it ignores it. The router parses the
"catchup:<codename>@<ts>" id (an explicit branch above _route) and
hands the parsed arguments to this manager.
HRTi has no catchup -- its VOD and EPG are separate domains, and
authorize_session's session id is not reused for timeshift.
@@ -30,22 +35,32 @@ State sharing
The catchup step often shares state with the channel manager (the live
manifest URL) or the EPG manager (the epg_id for the requested window).
Pass those collaborators as explicit keyword-only arguments rather than
reaching back to the provider.
reaching back to the provider (the provider's _build_catchup does this).
Do NOT fall back to the live manifest
-------------------------------------
If get_catchup_manifest cannot resolve catchup for the given window,
return None. Do not return the live manifest URL as a "catchup"
manifest -- the DRM pipeline would extract PSSH from the live stream,
which may differ from the catchup stream's encryption context.
which may differ from the catchup stream's encryption context. Callers
that want the live manifest on failure call provider.get_manifest().
end_time is Optional[int]
-------------------------
If the provider's API takes only a start bound, accept None and document
that it is ignored. If the API needs both bounds, raise BadRequestError
on None. Never pass a sentinel (0, start_time + 1800) when the ABC
accepts None.
"""
from typing import Any, Dict, List, Optional
from typing import List, Optional
from ...base.managers import CatchupManager
from ...base.models import DRMConfig
from ...base.utils.logger import logger
# from ...base.errors import BadRequestError
class YourCatchupManager(CatchupManager):
"""Catchup for {TODO: provider name}."""
@@ -68,7 +83,7 @@ class YourCatchupManager(CatchupManager):
)
# Common collaborators. Catchup often needs one or both.
# - channels: for resolving a channel's live manifest URL
# (Magenta) or its stream uid (MoveTV).
# (Magenta, simpliTV) or its stream uid (MoveTV).
# - epg: for resolving an epg_id from a start_time
# (MoveTV).
self._channels = channels
@@ -87,13 +102,16 @@ class YourCatchupManager(CatchupManager):
self,
content_id: str,
start_time: int,
end_time: int,
end_time: Optional[int] = None,
epg_id: Optional[str] = None,
**kw,
) -> Optional[str]:
"""
Return the catchup manifest URL, or None if not resolvable.
start_time / end_time are integer epoch seconds. end_time may be
None (see module docstring).
Do NOT fall back to the live manifest URL here.
"""
raise NotImplementedError("YourCatchupManager.get_catchup_manifest")
@@ -104,7 +122,7 @@ class YourCatchupManager(CatchupManager):
# self,
# content_id: str,
# start_time: int,
# end_time: int,
# end_time: Optional[int] = None,
# epg_id: Optional[str] = None,
# **kw,
# ) -> List[DRMConfig]:
@@ -3,14 +3,32 @@
{TODO: Provider name} channel manager.
Subclasses base.managers.ChannelManager. See ../_template/README.md.
Content-id grammar parsers for the whole provider live at MODULE SCOPE in
this file (the primary content-id namespace), and other managers import
them from here. One grammar, one parser. Parsers raise BadRequestError on
malformed input (the router does not catch it, so a bad id surfaces
instead of falling through to the wrong manager). Helpers shared across
managers are public -- no leading underscore.
"""
from typing import Any, Dict, List, Optional
from typing import Dict, List, Optional
from ...base.managers import ChannelManager
from ...base.models import Channel, DRMConfig
from ...base.models import Channel
from ...base.utils.logger import logger
# from ...base.errors import BadRequestError
# from ...base.models import DRMConfig
# ----- Content-id grammar parsers (module scope) -----
#
# def parse_live_id(content_id: str) -> str:
# if not content_id.startswith("live:"):
# raise BadRequestError(f"not a live id: {content_id!r}")
# return content_id[len("live:"):]
class YourChannelManager(ChannelManager):
"""Fetches live channels for {TODO: provider name}."""
@@ -38,6 +56,9 @@ class YourChannelManager(ChannelManager):
)
# ----- Abstract methods -----
#
# Return None / [] for "not in my domain"; raise for real failures
# (see the README's "Return-value rule").
def get_channels(self, **kw) -> List[Channel]:
raise NotImplementedError("YourChannelManager.get_channels")
@@ -51,5 +72,16 @@ class YourChannelManager(ChannelManager):
# ----- Optional overrides -----
# Override when the provider also has a VodManager: the default
# returns True, so without this the channel manager is tried first
# for every id.
#
# def handles_content_id(self, content_id: str) -> bool:
# return content_id.isdigit()
# return content_id.startswith(("live:", "rec:"))
# Folded DRM architecture (DRM shares state with the manifest step):
# override this and the provider's implements_drm flips to True
# automatically. Dedicated DRM manager instead? Leave it alone.
#
# def get_channel_drm(self, content_id: str, **kw) -> List[DRMConfig]:
# return []
@@ -2,8 +2,10 @@
"""
{TODO: Provider name} constants.
All URLs, endpoint paths, static header values, and default parameters
live here so no other file contains magic strings.
All URLs, endpoint paths, static header values, parameter names, and
default parameters live here so no other file contains magic strings.
That includes the provider's machine name: provider.py, auth.py and any
persistence keys read PROVIDER_NAME from here.
Structure:
* YourDefaults -- class-level constants.
@@ -11,12 +13,18 @@ Structure:
and URL builder methods.
"""
from typing import Dict, Optional
class YourDefaults:
# Lowercase, no spaces; must equal the plugin directory name.
PROVIDER_NAME = "TODO"
PROVIDER_LOGO = "TODO: url"
BASE_URL = "TODO: https://..."
# Multi-country providers: per-country overrides, keyed by the
# lowercase country code. Missing country -> BASE_URL.
BASE_URLS: Dict[str, str] = {}
WEBSITE = "TODO: https://..."
PATH_LOGIN = "/api/login"
@@ -26,6 +34,11 @@ class YourDefaults:
USER_AGENT = "TODO"
TIMEOUT = 30
# If the token travels in the URL or body instead of a header, keep
# the parameter names here (they may differ per endpoint) and let
# auth.with_token(url, param=...) pick one. Never hardcode them.
TOKEN_PARAM = "token"
# Static values the API expects (partner ids, client versions, ...).
@@ -33,14 +46,21 @@ class YourConfig:
"""
Per-instance configuration.
Attributes the template's provider.py relies on:
Attributes the template's provider.py and auth.py rely on:
user_agent -- string, passed to _setup_http_manager.
timeout -- int seconds, passed to _setup_http_manager.
base_url -- resolved for the instance's country.
"""
def __init__(self, config_dict: dict = None):
def __init__(
self, config_dict: Optional[dict] = None, country: Optional[str] = None
):
config = config_dict or {}
self.base_url = config.get("base_url", YourDefaults.BASE_URL)
self.country = (country or "").lower()
default_base = YourDefaults.BASE_URLS.get(
self.country, YourDefaults.BASE_URL
)
self.base_url = config.get("base_url", default_base)
self.user_agent = config.get("user_agent", YourDefaults.USER_AGENT)
self.timeout = config.get("timeout", YourDefaults.TIMEOUT)
# ... any other provider-specific config fields
@@ -48,6 +68,7 @@ class YourConfig:
# ----- Header builders -----
def get_base_headers(self) -> dict:
"""Static, non-auth headers. Auth.build_headers() starts from this."""
return {
"User-Agent": self.user_agent,
"Accept": "application/json",
@@ -12,6 +12,14 @@ 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.
Method names (not interchangeable)
----------------------------------
get_drm_configs -- the dedicated DrmManager's only method
get_channel_drm -- folded architecture, live-channel entry point
get_vod_drm -- folded architecture, VOD entry point
A dedicated manager implements get_drm_configs and leaves the other two
alone; a folded provider does the opposite.
Source patterns vs. architecture
--------------------------------
"Source pattern" describes HOW a provider obtains DRM material (an
@@ -38,28 +46,9 @@ license URL is produced, and adapt the mechanics.
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.
...
1. Class shape -- see YourDrmManager below. If content_type is None,
infer it from the content_id grammar; when in doubt, widen (a wrong
narrowing yields a silent [] for protected content).
2. Wiring
In provider.py's __init__:
@@ -112,6 +101,7 @@ How to structure a DRM manager for a new provider
Pattern C -- DRM arrives with the playback response
Files: providers/discovery/playback_manager.py
providers/discovery/constants.py
providers/simplitv/ (folded into the channel manager)
Summary: during get_manifest, POST playbackInfo and cache the
response. get_drm() is a cache lookup; no separate DRM call.
@@ -164,4 +154,36 @@ How to structure a DRM manager for a new provider
* 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.
"""
"""
from typing import List, Optional
from ...base.models import DRMConfig
from ...base.utils.logger import logger
class YourDrmManager:
"""Dedicated DRM manager. Matches DrmManagerProtocol by shape."""
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, ...
):
self.http_manager = http_manager
self.auth = auth
self.country = country
self.config = config
def get_drm_configs(
self,
content_id: str,
content_type: Optional[str] = None,
**opts,
) -> List[DRMConfig]:
raise NotImplementedError("YourDrmManager.get_drm_configs")
@@ -3,6 +3,16 @@
{TODO: Provider name} EPG manager.
Subclasses base.managers.EpgManager. See ../_template/README.md.
EPG does not participate in the provider's content-id router; get_epg
goes straight to this manager. EpgManager has handles_channel_id(),
used internally by get_epg_grid().
TIME HANDLING (TODO: state it for your provider): start_time/end_time are
datetimes. Decide and document whether you require tz-aware UTC values
and convert provider-local times at the boundary. Mixing naive and aware
datetimes (or local time zones) is the classic source of off-by-N-hours
guide bugs. Catchup, by contrast, takes integer epoch seconds.
"""
from datetime import datetime
@@ -0,0 +1,85 @@
# streaming_providers/providers/_template/favorites_manager.py
"""
{TODO: Provider name} favorites manager (optional).
Include this file only if the provider supports user favorites on
programs / channels. Providers without favorites don't create a
favorites manager -- the provider's implements_favorites is False and
calls to get_favorites return [].
See ../_template/README.md ("Favorites and bookmarks") for the contract.
Contract summary
----------------
* User-scoped; operates on whatever content_id the caller provides, so
it does not participate in the content_id router.
* Each returned Favorite carries a FavoriteType (program / channel /
clip / live / event). Providers that support only some types validate
the incoming type in add_favorite and reject the others
(BadRequestError).
* Removing a non-existent favorite raises KeyError.
* Backend failures raise a ProviderError subclass from base.errors.
VERIFY the imports and signatures below against
base/provider_mixins/favorites.py (ProviderFavoritesMixin) and the
FavoritesManager ABC when you copy this file -- this scaffold mirrors the
bookmarks template and the README, not the ABC source.
"""
from typing import List, Optional
from ...base.managers import FavoritesManager
from ...base.models.favorite import Favorite, FavoriteType # adjust path
from ...base.utils.logger import logger
class YourFavoritesManager(FavoritesManager):
"""Favorites for {TODO: provider name}."""
def __init__(
self,
*,
http_manager,
auth,
country,
config,
favorites_cache=None,
):
super().__init__(
http_manager=http_manager,
auth=auth,
country=country,
config=config,
)
self._favorites_cache = (
favorites_cache if favorites_cache is not None else {}
)
# ----- Abstract methods -----
def get_favorites(self, **kw) -> List[Favorite]:
"""Return all favorites for the user. [] when there are none."""
raise NotImplementedError("YourFavoritesManager.get_favorites")
def add_favorite(
self,
content_id: str,
favorite_type: Optional[FavoriteType] = None,
**kw,
) -> Favorite:
"""
Add a favorite.
Reject unsupported FavoriteType values with BadRequestError.
"""
raise NotImplementedError("YourFavoritesManager.add_favorite")
def remove_favorite(self, content_id: str, **kw) -> None:
"""
Remove a favorite.
Raises:
KeyError: if no favorite exists for content_id.
ProviderError: (a subclass) on backend failure.
"""
raise NotImplementedError("YourFavoritesManager.remove_favorite")
@@ -2,137 +2,134 @@
"""
{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).
What is needed:
* A custom AuthToken subclass -- MANDATORY for any provider with auth.
BaseAuthToken is an ABC with an abstract to_dict(), so it cannot be
instantiated directly. The minimal subclass below is live code, not
an example; auth.py imports it.
* A custom Channel subclass -- optional (extra per-channel fields; see
MoveTV's MoveTVChannel, Discovery's DiscoveryChannel, simpliTV's
SimpliTVChannel).
* A custom Credentials subclass -- optional (unusual login 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.
A provider WITHOUT auth can delete the AuthToken subclass. A provider that
uses plain Channel and UserPasswordCredentials needs nothing else here.
Rules
-----
* When overriding to_dict(), call super().to_dict() and add your fields.
Both Channel.to_dict() and BaseAuthToken.to_dict() chain correctly.
Channel.to_dict() chains correctly. BaseAuthToken.to_dict() is abstract,
so an AuthToken subclass implements it in full.
* to_dict() keys on Channel subclasses are TitleCase, no underscores
("YourField"), matching the base serializer.
* Custom Channel subclasses are returned from ChannelManager.get_channels()
as-is; nothing in the base inspects the concrete type.
as-is; nothing in the base inspects the concrete type. Use the inherited
factories (create_live_channel / create_vod_channel / create_radio_channel);
they use cls(...) and therefore return your subclass.
* 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).
"""
from dataclasses import dataclass
from typing import Any, Dict
from ...base.auth.base_auth import BaseAuthToken
# from ...base.models import Channel
# from ...base.auth.credentials import UserPasswordCredentials
# ---------------------------------------------------------------------------
# AuthToken subclass (mandatory when the provider has auth)
# ---------------------------------------------------------------------------
@dataclass
class YourAuthToken(BaseAuthToken):
"""
Minimal concrete token.
Add provider-specific claims as new fields WITH DEFAULTS, after the
base fields, and include them in to_dict()/from_dict().
to_dict() must exist even if you never persist tokens (the ABC
requires it). Implement it for real so enabling persistence later
needs no follow-up edit.
VERIFY against base/auth/base_auth.py: the field list below mirrors
the README example. If BaseAuthToken has required fields not listed
here, add them to to_dict() and from_dict().
"""
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,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "YourAuthToken":
"""
Reconstruct from a persisted dict. Used by Auth._load_session().
Mirror to_dict(). auth_level is serialized via `.value`, so it
must be converted back to its enum here (see base_auth.py);
until you do, keep persistence off or let _load_session() return
None -- a failed load only costs one re-authentication.
"""
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),
refresh_token=data.get("refresh_token"),
refresh_expires_in=data.get("refresh_expires_in", 0),
# TODO: auth_level=..., credential_type=...
)
# ---------------------------------------------------------------------------
# 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.
# them so positional construction still works. Never remove or rename
# base fields -- downstream consumers read them.
# """
#
# # Provider-specific extras.
# your_field: str = ""
# your_expires_at: float = 0.0
# codename: str = ""
# recording_id: str = ""
#
# def to_dict(self) -> Dict[str, Any]:
# result = super().to_dict()
# result["YourField"] = self.your_field
# result["YourExpiresAt"] = self.your_expires_at
# result["Codename"] = self.codename
# result["RecordingId"] = self.recording_id
# 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
# Only needed when the login payload isn't the usual
# {username, password} (HRTi's grant_access takes
# {Username, Password, OperatorReferenceId}, for example).
# """
#
@@ -8,11 +8,19 @@ the public StreamingProvider interface.
Subclasses the existing StreamingProvider. Does NOT subclass any new
base class. Authentication is lazy -- no network I/O in __init__.
All seven managers are optional. This template shows the shape for a
provider that has channels, VOD, and EPG. Delete the factories for
capabilities you don't have, or return None from them.
There are seven optional manager ABCs (channels, vod, epg, recordings,
favorites, bookmarks, catchup) plus an optional DRM manager (a protocol,
not an ABC). This template shows the shape for a provider that has
channels, VOD, and EPG. Delete the factories for capabilities you don't
have, or return None from them.
Plugin name: the registry derives it from the CLASS NAME via
`cls.__name__.lower().replace("provider", "")`. `YourProvider` becomes
"your". Name the class so that this matches your plugin directory, e.g.
`SimpliTVProvider` -> "simplitv" -> providers/simplitv/.
"""
from datetime import datetime
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
from ...base.errors import NotFoundError
@@ -21,10 +29,12 @@ from ...base.models.proxy_models import ProxyConfig
from ...base.protocols import DrmManagerProtocol
from ...base.provider import StreamingProvider
from ...base.utils.logger import logger
from ...base.vod import VodPage
from .auth import YourProviderAuth
from .channel_manager import YourChannelManager
from .constants import YourConfig
from .constants import YourConfig, YourDefaults
# from .channel_manager import parse_catchup_id # if you route catchup
# from .vod_manager import YourVodManager
# from .epg_manager import YourEpgManager
# from .recordings_manager import YourRecordingsManager
@@ -55,20 +65,20 @@ class YourProvider(StreamingProvider):
# The value is the machine identifier: lowercase, no spaces, matching
# the plugin directory name and the PROVIDER_NAME constant in
# constants.py. Used in settings keys, log lines, and the `provider`
# field on models.
# field on models. Return the constant so the two cannot drift.
#
# Do not delete this property. Override the return value; do not
# replace it with a class attribute.
@property
def provider_name(self) -> str:
return "TODO: provider_name"
return YourDefaults.PROVIDER_NAME
# ------------------------------------------------------------------
# Class metadata
# Class metadata (read by the registry BEFORE any instance exists)
# ------------------------------------------------------------------
PROVIDER_LABEL: ClassVar[str] = "TODO: display label"
PROVIDER_LOGO: ClassVar[str] = "TODO: logo url"
PROVIDER_LOGO: ClassVar[str] = YourDefaults.PROVIDER_LOGO
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["user_credentials"]
# ALWAYS set SUPPORTED_COUNTRIES. Never leave it at the base
@@ -78,7 +88,8 @@ class YourProvider(StreamingProvider):
#
# Single country: ["AT"]
# Multi-country: ["hr", "pl", "me", "at", "hu"]
# Wildcard: ["*"] (country discovered at runtime)
# Wildcard: ["*"] (country discovered at runtime;
# also the right answer for "not sure yet")
#
# See the README's "SUPPORTED_COUNTRIES is not optional" section.
SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["TODO"]
@@ -100,8 +111,16 @@ class YourProvider(StreamingProvider):
):
super().__init__(country)
# Unknown kwargs are tolerated (the registry may pass host-level
# extras) but never silently: a typo here is otherwise invisible.
if kwargs:
logger.debug(
f"{self.provider_name}: ignoring unknown kwargs "
f"{sorted(kwargs)}"
)
config = config or {}
self.config = YourConfig(config)
self.config = YourConfig(config, country=self.country)
# 1. HTTP manager.
self.http_manager = self._setup_http_manager(
@@ -113,10 +132,10 @@ class YourProvider(StreamingProvider):
# 2. Auth (lazy -- no network call in __init__).
#
# Providers WITHOUT auth: leave self.auth = None, and either
# accept the manager base constructors' AuthProtocol warning,
# or provide a minimal stub with the three methods. See the
# README's "Providers without auth" section.
# Providers WITHOUT auth: _build_auth returns None (accept the
# manager base constructors' AuthProtocol warning) or a minimal
# stub with the three token methods. See the README's
# "Providers without auth" section.
self._credentials = credentials
self.auth = self._build_auth(settings_manager)
@@ -125,16 +144,17 @@ class YourProvider(StreamingProvider):
self._channels_cache: Dict = {}
self._playback_cache: Dict = {}
# 4. Managers. Every factory returns a manager or None.
# Delete the lines for capabilities you don't have, or leave
# the corresponding _build_* returning None.
# 4. Managers, in DEPENDENCY ORDER. Every factory returns a
# manager or None. Catchup (and a dedicated DRM manager) may
# need channels/epg/vod, so they are built after them. Do not
# introduce cycles between managers.
self.channels = self._build_channels()
self.vod = self._build_vod()
self.epg = self._build_epg()
self.recordings = self._build_recordings()
self.favorites = self._build_favorites()
self.bookmarks = self._build_bookmarks()
self.catchup = self._build_catchup()
self.catchup = self._build_catchup() # sees channels + epg
self.drm = self._build_drm()
# ------------------------------------------------------------------
@@ -146,13 +166,16 @@ class YourProvider(StreamingProvider):
Return the provider's Auth instance, or None if the provider
needs no authentication.
See the README's "Providers without auth" section for the
minimal stub shape.
`credentials=` MUST be forwarded: it is credentials source #1
(constructor argument). The auth class falls back to the
settings_manager and CredentialManager on its own.
"""
return YourProviderAuth(
http_manager=self.http_manager,
country=self.country,
settings_manager=settings_manager,
credentials=self._credentials,
config=self.config,
)
def _build_channels(self) -> Optional[ChannelManager]:
@@ -210,10 +233,18 @@ class YourProvider(StreamingProvider):
"""
Return a CatchupManager, or None.
Catchup usually needs the channel manager as a collaborator
(to reuse the live manifest fetch). Ensure
self.channels is built before this factory runs.
Catchup usually needs the channel manager and/or the EPG manager
as collaborators. Pass them as explicit keyword-only arguments;
self.channels and self.epg are already built when this runs.
"""
# TODO: return YourCatchupManager(
# http_manager=self.http_manager,
# auth=self.auth,
# country=self.country,
# config=self.config,
# channels=self.channels,
# epg=self.epg,
# )
return None
def _build_drm(self) -> Optional[DrmManagerProtocol]:
@@ -231,10 +262,9 @@ class YourProvider(StreamingProvider):
get_vod_drm() on your VodManager. The provider's get_drm()
falls back to routing to those.
New providers should prefer the dedicated manager unless the
DRM step shares state with the manifest step. See
providers/_template/drm_manager.py for the four existing
source patterns.
Rule: fold if DRM shares state with the manifest step; otherwise
use the dedicated manager. See providers/_template/drm_manager.py
for the four existing source patterns.
"""
return None
@@ -315,6 +345,13 @@ class YourProvider(StreamingProvider):
If nobody resolved and a NotFoundError was seen, re-raise it --
that's "the content existed in some manager's domain but is gone",
distinct from "nobody handles this id at all" (which returns None).
Any OTHER exception propagates. In particular BadRequestError is
deliberately NOT caught: if a manager uses a 400 as an endpoint
dispatch signal, override handles_content_id() on that manager
instead of relying on try-and-catch. When both channels and vod
exist, override handles_content_id() on both -- the default
(True) makes the first manager see every id.
"""
last_not_found: Optional[NotFoundError] = None
for manager, call in attempts:
@@ -333,6 +370,13 @@ class YourProvider(StreamingProvider):
# ------------------------------------------------------------------
# Public delegations
#
# The optional capabilities (recordings, favorites, bookmarks,
# catchup) are exposed by the base layer's Provider*Mixin classes,
# which correspond one-to-one to those managers -- no delegation is
# needed here. Channels, VOD and EPG are delegated explicitly below.
# VERIFY these three signatures against StreamingProvider when you
# copy the template; remove any the base class already provides.
# ------------------------------------------------------------------
def get_channels(self, **kw):
@@ -340,15 +384,50 @@ class YourProvider(StreamingProvider):
return []
return self.channels.get_channels(**kw)
def get_vod_category(
self,
content_id: str = "",
cursor: Optional[str] = None,
page_size: int = 24,
**kw,
) -> VodPage:
if self.vod is None:
return VodPage()
return self.vod.get_vod_category(
content_id, cursor=cursor, page_size=page_size, **kw
)
def get_epg(
self,
channel_id: str,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
**kw,
):
if self.epg is None:
return []
return self.epg.get_epg(
channel_id, start_time=start_time, end_time=end_time, **kw
)
def get_manifest(self, content_id: str, **kw) -> Optional[str]:
"""
Return the manifest URL for the given content, routing by manager.
Providers with catchup, events, or other content types extend
this method with additional branches. Providers with a
structured content_id grammar add explicit prefix branches
above _route when the manager needs parsed arguments -- see the
README's "Parsers vs. dispatch".
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).
One parser, in the router -- see the README's "Parsers vs.
dispatch". Catchup is the usual example:
# if content_id.startswith("catchup:"):
# parsed = parse_catchup_id(content_id) # raises
# return self.catchup.get_catchup_manifest( # BadRequestError
# parsed.content_id, parsed.start_time, parsed.end_time,
# **kw,
# ) if self.catchup else None
Do NOT fall back to the live manifest when catchup fails.
"""
return self._route(content_id, [
(self.channels, lambda m: m.get_channel_manifest(
@@ -12,7 +12,7 @@ See ../_template/README.md for the contract.
Reference implementation
------------------------
simpliTV's SimpliTVRecordingsManager
(providers/simpli/recordings_manager.py) is the first example: it
(providers/simplitv/recordings_manager.py) is the first example: it
returns recordings as SimpliTVChannel objects, carries recording_id
on the subclass, and paginates through /v2/Pvr/GetRecordings.
@@ -22,9 +22,13 @@ A recording has its own id (recording_id) distinct from the content_id
of the underlying programme. The two namespaces are usually different:
recording_id is what you pass to delete_recording, content_id is what
you pass to get_manifest to play the recording. Keep them separate.
Recordings do not participate in the content_id router (they have their
own id namespace); the manager may override handles_recording_id() when
several recordings managers exist (cloud PVR + local PVR, say).
"""
from typing import Any, List
from typing import List
from ...base.managers import RecordingsManager
from ...base.models import Channel
@@ -51,7 +55,9 @@ class YourRecordingsManager(RecordingsManager):
Do not add a get_manifest method here. Route it in the
provider's get_manifest instead. See
providers/simpli/provider.py for the pattern.
providers/simplitv/provider.py for the pattern. (Two managers
may accept the same prefix, e.g. "rec:", when their concerns
are disjoint -- but only ONE router branch per prefix.)
Recording identity
------------------
@@ -97,8 +103,9 @@ class YourRecordingsManager(RecordingsManager):
Delete a recording.
Raises:
KeyError: if the recording doesn't exist.
ProviderError: on backend failure.
KeyError: if the recording doesn't exist.
ProviderError: (a subclass from base.errors) on backend
failure. Never return silently on failure.
"""
raise NotImplementedError("YourRecordingsManager.delete_recording")
@@ -5,15 +5,20 @@
Subclasses base.managers.VodManager. Document the content_id grammar here.
See ../_template/README.md for the contract.
Every navigation method returns VodPage. To paginate use `page.has_more`
(NOT bool(page)); `next_cursor is None` is the authoritative end-of-list
signal and `total` is informational only.
"""
from typing import Any, Dict, Optional
from typing import Dict, List, Optional
from ...base.managers import VodManager
from ...base.models import DRMConfig
from ...base.vod import VodPage
from ...base.utils.logger import logger
# from ...base.models import DRMConfig
class YourVodManager(VodManager):
"""
@@ -63,5 +68,13 @@ class YourVodManager(VodManager):
# ----- Optional overrides -----
# Override when the provider also has a ChannelManager (see the
# channel manager template for why).
#
# def handles_content_id(self, content_id: str) -> bool:
# return content_id.startswith(("details_", "clip_"))
# return content_id.startswith(("details_", "clip_"))
# Folded DRM architecture; see the channel manager template.
#
# def get_vod_drm(self, content_id: str, **kw) -> List[DRMConfig]:
# return []