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