mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-05 23:42:57 +02:00
Update template
This commit is contained in:
@@ -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 []
|
||||
Reference in New Issue
Block a user