work towards provider v2

This commit is contained in:
Nirvana
2026-10-07 12:12:10 +02:00
parent 777101f89d
commit 04ededea69
24 changed files with 1428 additions and 864 deletions
@@ -88,42 +88,37 @@ class WafBlockedException(Exception):
pass
class SessionAwareHTTPManager:
"""Wraps http_manager to provide session-like cookie handling while maintaining proxy support"""
"""
Thin wrapper over HTTPManager for OAuth flows: shared headers and
operation="auth" (so proxy scope honours ProxyScope.authentication).
Cookies are handled entirely by the underlying requests.Session cookie
jar. Never set a 'Cookie' header manually here — doing so overrides
requests' own cookie handling and drops intermediate-redirect cookies
(e.g. Joyn's `status_id`), which breaks multi-step OAuth flows.
The `headers` dict is merged into every request; per-call headers win
over session-level ones.
"""
def __init__(self, http_manager):
self.http_manager = http_manager
self.cookies: Dict[str, str] = {}
self.headers: Dict[str, str] = {}
def _prepare(self, kwargs):
kwargs.setdefault("operation", "auth")
merged = {**self.headers, **(kwargs.get("headers") or {})} # per-call wins
if merged:
kwargs["headers"] = merged
else:
kwargs.pop("headers", None)
return kwargs
def get(self, url: str, **kwargs):
"""GET request with cookie handling"""
headers = kwargs.get("headers", {}).copy()
headers.update(self.headers)
if self.cookies:
cookie_str = "; ".join([f"{k}={v}" for k, v in self.cookies.items()])
headers["Cookie"] = cookie_str
kwargs["headers"] = headers
response = self.http_manager.get(url, operation="oauth", **kwargs)
self._update_cookies_from_response(response)
return response
return self.http_manager.get(url, **self._prepare(kwargs))
def post(self, url: str, **kwargs):
"""POST request with cookie handling"""
headers = kwargs.get("headers", {}).copy()
headers.update(self.headers)
if self.cookies:
cookie_str = "; ".join([f"{k}={v}" for k, v in self.cookies.items()])
headers["Cookie"] = cookie_str
kwargs["headers"] = headers
response = self.http_manager.post(url, operation="oauth", **kwargs)
self._update_cookies_from_response(response)
return response
def _update_cookies_from_response(self, response):
"""Extract and update cookies from response"""
if hasattr(response, "cookies"):
for cookie in response.cookies:
self.cookies[cookie.name] = cookie.value
return self.http_manager.post(url, **self._prepare(kwargs))
# OAuth2RemoteLoginMixin comes first so its methods take precedence over any
@@ -519,11 +514,10 @@ class BaseOAuth2Authenticator(OAuth2RemoteLoginMixin, BaseAuthenticator):
def _create_oauth_session(self) -> SessionAwareHTTPManager:
"""Create a session-aware HTTP manager for OAuth flows"""
session = SessionAwareHTTPManager(self.http_manager)
session.headers.update({
"User-Agent": self.config.user_agent,
"Referer": getattr(self.config, "base_website", ""),
"Origin": getattr(self.config, "base_website", ""),
})
base = getattr(self.config, "base_website", "")
session.headers["User-Agent"] = self.config.user_agent
if base:
session.headers.update({"Referer": base, "Origin": base})
return session
# ========================================================================
+40
View File
@@ -182,6 +182,25 @@ class NotFoundError(ProviderError):
"""
class ItemNotFoundError(NotFoundError, KeyError):
"""A user-scoped item (recording, favorite, bookmark) does not exist.
Raised by delete_recording / remove_favorite / delete_bookmark when the
id is not currently stored. It is deliberately BOTH a NotFoundError
(uniform ProviderError handling) and a KeyError (the pre-existing
convention documented on the Provider*Mixin classes), so callers can
migrate from ``except KeyError`` to ``except ItemNotFoundError`` at
their own pace. Do not confuse with "this manager doesn't handle that
content_id", which is a None / [] return value.
"""
def __str__(self) -> str:
# KeyError.__str__ would repr() the message (extra quotes).
if len(self.args) == 1:
return str(self.args[0])
return Exception.__str__(self)
class BadRequestError(ProviderError):
"""400 -- the request was malformed for the endpoint called.
@@ -220,10 +239,31 @@ class NotImplementedYetError(ProviderError):
"""Feature captured but not yet wired up."""
class UnsupportedOperationError(NotImplementedYetError):
"""The provider does not support this operation (and will not).
Distinct in meaning from NotImplementedYetError ("planned, not done").
Subclasses it so existing handlers keep working. Prefer checking the
capability flag (e.g. RecordingsManager.supports_scheduling) before
calling, and treat this as the safety net.
"""
class ConfigurationError(ProviderError):
"""Provider is misconfigured."""
class OperationFailedError(ProviderError, RuntimeError):
"""A write operation was rejected or failed on the provider backend.
Raised by add_favorite / remove_favorite / update_bookmark /
delete_bookmark / delete_recording on backend refusal or failure. Also a
RuntimeError so existing ``except RuntimeError`` handlers keep working.
Prefer a more specific subclass of ProviderError (ServerError,
EntitlementError, ...) when the cause is known.
"""
# ---------------------------------------------------------------------------
# HTTP status -> error class heuristic
# ---------------------------------------------------------------------------
@@ -0,0 +1,376 @@
# streaming_providers/base/managed_provider.py
"""
ManagedProvider -- StreamingProvider plus manager composition. DRAFT.
Supersedes provider_v2.py. It SUBCLASSES StreamingProvider, so registry
metadata, isinstance checks in the backend and the legacy mixins keep
working. Only new/migrated providers use it; legacy providers are
untouched.
It moves out of every provider (see simplitv/provider.py) what is
identical in all of them:
* the _build_*() hooks (default None = capability absent)
* the implements_* flags
* _route() and the default get_manifest() / get_drm() routing
* get_channels(), EPG + header delegation, DRM validation
A provider still owns: http/auth/cache setup, the _build_*() bodies, and
anything with provider-specific grammar (simplitv: the catchup: branch
and get_restart()).
Usage in a provider's __init__, after http_manager / auth / caches exist:
self._init_managers()
and for provider-specific prefixes:
def get_manifest(self, content_id, **kw):
if content_id.startswith(CATCHUP_PREFIX):
...
return super().get_manifest(content_id, **kw)
Routing rules
-------------
1. handles_content_id() is a cheap pre-filter; False skips the manager.
2. A manager that passes may still return None/[]; the next is tried.
3. NotFoundError is remembered and re-raised only if nobody resolves.
4. BadRequestError is never caught.
5. The manager that resolved an id is remembered (bounded, locked), so
headers go to the same manager that produced the manifest.
6. content_type narrows only for "live" and "vod"; anything else widens.
"""
from __future__ import annotations
import threading
from collections import OrderedDict
from datetime import datetime
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
from .errors import ConfigurationError, NotFoundError
from .managers import (
BookmarksManager,
CatchupManager,
ChannelManager,
EpgManager,
FavoritesManager,
RecordingsManager,
VodManager,
)
from .models import StreamingChannel
# Module paths below assume models/drm/{exceptions,drm_config}.py (see TODO R-1).
from .models.drm.drm_config import validate_drm_set
from .models.drm.exceptions import DRMError
from .protocols import DrmManagerProtocol
from .provider import StreamingProvider
_ROUTED = ("channels", "vod")
class ManagedProvider(StreamingProvider):
# Folded DRM: managers override get_channel_drm / get_vod_drm.
# Explicit on purpose -- no override sniffing via method identity.
DRM_IN_MANAGERS: ClassVar[bool] = False
# True: header hooks on the managers are honoured (default is then
# auth.build_headers(), NOT the legacy {}). Set False to keep legacy {}.
HEADERS_FROM_MANAGERS: ClassVar[bool] = True
ROUTE_CACHE_SIZE: ClassVar[int] = 512
# NOTE: `self.channels` holds the ChannelManager here (template
# convention) and shadows the legacy list set by StreamingProvider.
# to_output_format() is overridden below for that reason.
# ------------------------------------------------------------------
# Wiring
# ------------------------------------------------------------------
def _init_managers(self) -> None:
"""Build managers in dependency order (catchup sees channels)."""
self._route_cache: "OrderedDict[str, str]" = OrderedDict()
self._route_lock = threading.Lock()
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.drm = self._build_drm()
self._check_wiring()
def _build_channels(self) -> Optional[ChannelManager]:
return None
def _build_vod(self) -> Optional[VodManager]:
return None
def _build_epg(self) -> Optional[EpgManager]:
return None
def _build_recordings(self) -> Optional[RecordingsManager]:
return None
def _build_favorites(self) -> Optional[FavoritesManager]:
return None
def _build_bookmarks(self) -> Optional[BookmarksManager]:
return None
def _build_catchup(self) -> Optional[CatchupManager]:
return None
def _build_drm(self) -> Optional[DrmManagerProtocol]:
return None
def _check_wiring(self) -> None:
expected = {
"channels": ChannelManager,
"vod": VodManager,
"epg": EpgManager,
"recordings": RecordingsManager,
"favorites": FavoritesManager,
"bookmarks": BookmarksManager,
"catchup": CatchupManager,
}
for attr, abc_type in expected.items():
mgr = getattr(self, attr)
if mgr is not None and not isinstance(mgr, abc_type):
raise ConfigurationError(
f"{type(self).__name__}.{attr} is {type(mgr).__name__}, "
f"expected a {abc_type.__name__}"
)
if self.drm is not None and self.DRM_IN_MANAGERS:
raise ConfigurationError(
f"{type(self).__name__}: use _build_drm() OR "
f"DRM_IN_MANAGERS, not both"
)
# ------------------------------------------------------------------
# Capability flags (one signal each)
# ------------------------------------------------------------------
@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:
# Manager present AND it reports a window (epg_window != (0, 0)).
return self.epg is not None and self.epg.implements_epg
@property
def implements_recordings(self) -> bool:
return self.recordings is not None
@property
def implements_favorites(self) -> bool:
return self.favorites is not None
@property
def implements_bookmarks(self) -> bool:
return self.bookmarks is not None
@property
def implements_catchup(self) -> bool:
return self.catchup is not None and self.catchup.supports_catchup
@property
def implements_drm(self) -> bool:
return self.drm is not None or self.DRM_IN_MANAGERS
@property
def capabilities(self) -> Dict[str, bool]:
"""All derived flags in one dict (same keys the registry reports)."""
return {
name: getattr(self, f"implements_{name}")
for name in (
"channels", "vod", "epg", "recordings", "favorites",
"bookmarks", "catchup", "drm",
)
}
# ------------------------------------------------------------------
# Router
# ------------------------------------------------------------------
def _remember(self, content_id: str, name: str) -> None:
with self._route_lock:
self._route_cache[content_id] = name
self._route_cache.move_to_end(content_id)
while len(self._route_cache) > self.ROUTE_CACHE_SIZE:
self._route_cache.popitem(last=False)
def _route(
self,
content_id: str,
attempts: List[Tuple[str, Callable[[Any], Any]]],
) -> Any:
"""
attempts: [(manager attribute name, call(manager)), ...]
A falsy result (None, "", []) means "not mine".
"""
last_not_found: Optional[NotFoundError] = None
for name, call in attempts:
manager = getattr(self, name, None)
if manager is None or not manager.handles_content_id(content_id):
continue
try:
result = call(manager)
except NotFoundError as exc:
last_not_found = exc
continue
if result:
self._remember(content_id, name)
return result
if last_not_found is not None:
raise last_not_found
return None
def _routed_manager(self, content_id: str) -> Tuple[Optional[str], Any]:
"""Manager that resolved this id before, else the first that accepts it."""
with self._route_lock:
name = self._route_cache.get(content_id)
if name is not None:
return name, getattr(self, name, None)
for candidate in _ROUTED:
mgr = getattr(self, candidate, None)
if mgr is not None and mgr.handles_content_id(content_id):
return candidate, mgr
return None, None
# ------------------------------------------------------------------
# Public surface
# ------------------------------------------------------------------
def get_channels(self, **kw: Any) -> List[StreamingChannel]:
if self.channels is None:
return []
return self.channels.get_channels(**kw)
# --- EPG -----------------------------------------------------------
# The legacy ProviderEpgMixin never looks at self.epg: epg_window is
# (0, 0), get_epg() returns [] and get_epg_grid() returns {} unless the
# provider overrides them. Delegate once, here.
@property
def epg_window(self) -> Tuple[int, int]:
return self.epg.epg_window if self.epg is not None else (0, 0)
def get_epg(
self,
channel_id: str,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
country: Optional[str] = None, # legacy arg; the manager has its own
**kw: Any,
) -> List:
if not self.implements_epg or not self.epg.handles_channel_id(channel_id):
return []
return self.epg.get_epg(
channel_id, start_time=start_time, end_time=end_time, **kw
)
def get_epg_grid(
self,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
channel_ids: Optional[List[str]] = None,
country: Optional[str] = None,
**kw: Any,
) -> Dict[str, List]:
# Legacy order is (start, end, channel_ids); the ABC's is
# (channel_ids, start, end). Always pass by keyword.
if not self.implements_epg:
return {}
if channel_ids is None:
# Assumes EPG channel_id == content_id; verify per provider.
channel_ids = [c.content_id for c in self.get_channels()]
return self.epg.get_epg_grid(
channel_ids, start_time=start_time, end_time=end_time, **kw
)
def get_program_details(self, program_id: str, **kw: Any):
if not self.implements_epg:
return None
return self.epg.get_program_details(program_id, **kw)
def to_output_format(self, channels: Optional[List[StreamingChannel]] = None) -> Dict:
# Legacy implementation iterates self.channels, which is a manager here.
if channels is None:
channels = self.get_channels()
return super().to_output_format(channels)
def get_manifest(self, content_id: str, **kw: Any) -> Optional[str]:
return self._route(content_id, [
("channels", lambda m: m.get_channel_manifest(content_id, **kw)),
("vod", lambda m: m.get_vod_manifest(content_id, **kw)),
])
def get_manifest_headers(self, content_id: str, **kw: Any) -> Dict[str, str]:
if not self.HEADERS_FROM_MANAGERS:
return super().get_manifest_headers(content_id, **kw)
name, mgr = self._routed_manager(content_id)
if name == "channels":
return mgr.get_channel_manifest_headers(content_id, **kw)
if name == "vod":
return mgr.get_vod_manifest_headers(content_id, **kw)
return super().get_manifest_headers(content_id, **kw)
def get_segment_headers(self, content_id: str, **kw: Any) -> Dict[str, str]:
if self.HEADERS_FROM_MANAGERS:
name, mgr = self._routed_manager(content_id)
if name in _ROUTED: # both ChannelManager and VodManager have the hook
return mgr.get_segment_headers(content_id, **kw)
return self.get_manifest_headers(content_id, **kw)
def get_drm(
self,
content_id: str,
drm_variant: Optional[str] = None, # same position as legacy
content_type: Optional[str] = None, # "live" | "vod" narrow; else widen
**kw: Any,
) -> List:
if drm_variant is not None:
kw["drm_variant"] = drm_variant
if self.drm is not None:
configs = self.drm.get_drm_configs(
content_id, content_type=content_type, **kw
)
else:
attempts: List[Tuple[str, Callable[[Any], Any]]] = []
if content_type != "vod":
attempts.append(
("channels", lambda m: m.get_channel_drm(content_id, **kw))
)
if content_type != "live":
attempts.append(
("vod", lambda m: m.get_vod_drm(content_id, **kw))
)
configs = self._route(content_id, attempts) or []
return self._validate_drm(content_id, configs)
def _validate_drm(self, content_id: str, configs: List) -> List:
"""
Reject configs ISA would mishandle: invalid, or equal priorities.
Delegates to validate_drm_set (models/drm/drm_config.py). Note the
factories (create_widevine, create_playready, ...) all default to
priority=1, so a provider returning several DRMs must assign
priorities (or use merge_drm_configs(..., auto_priority=True)).
"""
try:
validate_drm_set(configs)
except DRMError as exc:
raise ConfigurationError(
f"{type(self).__name__}: invalid DRM configuration for "
f"{content_id}: {exc}"
) from exc
return configs
@@ -8,6 +8,8 @@ methods (headers, DRM defaults, search no-ops) come for free.
Design rules
------------
* All managers share one constructor contract, implemented once in the
private ManagerBase (managers/_base.py).
* 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
@@ -30,12 +32,17 @@ conventions documented in the template.
Manager list
------------
Three required capabilities:
ChannelManager
VodManager (providers without a browseable catalogue return None)
EpgManager (providers without EPG return None)
All seven managers are OPTIONAL (see providers/_template/README.md): a
provider wires the ones its service offers and returns None from the other
_build_*() factories. A VOD-only provider has no ChannelManager; a
metadata-only provider may have just an EpgManager.
Four optional capabilities -- providers implement the ones they support:
Core capabilities:
ChannelManager (live channels, channel manifest / DRM)
VodManager (browseable catalogue)
EpgManager (guide data)
Four further capabilities -- providers implement the ones they support:
RecordingsManager (cloud / network PVR)
FavoritesManager (user bookmarks on programs / channels)
BookmarksManager (resume position)
@@ -16,14 +16,19 @@ Return-value conventions
------------------------
get_bookmarks returns [] when the user has no bookmarks. Not an error.
update_bookmark raises RuntimeError if the provider rejects the write
update_bookmark raises OperationFailedError if the provider rejects the write
(e.g. content inaccessible, backend error). It is called on every
playback stop / pause, so providers should tolerate a write that
overwrites the same position with a no-op rather than failing.
delete_bookmark raises KeyError if no bookmark exists for content_id,
so callers can distinguish "already gone" from "successfully deleted".
The base ProviderBookmarksMixin documents the same rule.
delete_bookmark raises ItemNotFoundError if no bookmark exists for
content_id, so callers can distinguish "already gone" from "successfully
deleted". The base ProviderBookmarksMixin documents the same rule (as
KeyError; ItemNotFoundError is a KeyError subclass).
ItemNotFoundError is both a NotFoundError (ProviderError) and a KeyError, and
OperationFailedError is both a ProviderError and a RuntimeError, so handlers
written against the old KeyError / RuntimeError convention keep working.
Caller guidance
---------------
@@ -35,36 +40,16 @@ they are given.
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Any, List, Optional
from ..models.bookmark import Bookmark, ContentType
from ..protocols import AuthProtocol
from ..utils.logger import logger
from ._base import ManagerBase
class BookmarksManager(ABC):
class BookmarksManager(ManagerBase):
"""Abstract base for provider bookmarks 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
# ------------------------------------------------------------------
# Abstract
# ------------------------------------------------------------------
@@ -94,7 +79,8 @@ class BookmarksManager(ABC):
Called on playback stop / pause, so should be tolerant of
repeated writes to the same position (a no-op write is fine).
Raises RuntimeError if the provider rejects the write.
Raises OperationFailedError (also a RuntimeError) if the provider
rejects the write.
"""
raise NotImplementedError
@@ -104,7 +90,8 @@ class BookmarksManager(ABC):
Delete a bookmark.
Raises:
KeyError: if no bookmark exists for content_id.
RuntimeError: on backend failure.
ItemNotFoundError: if no bookmark exists for content_id
(also a KeyError).
OperationFailedError: on backend failure (also a RuntimeError).
"""
raise NotImplementedError
@@ -9,6 +9,7 @@ Public interface
get_catchup_manifest(content_id, start_time, end_time=None, ...)
-> Optional[str] [abstract]
get_catchup_manifest_headers(...) -> Dict[str,str] [concrete]
get_catchup_segment_headers(...) -> Dict[str,str] [concrete]
get_catchup_drm(...) -> List[DRMConfig] [concrete]
Constructor contract
@@ -30,8 +31,8 @@ get_catchup_drm returns [] when catchup shares DRM with live (the
common case), or a provider-specific list when catchup uses different
DRM.
Return the manifest of the live stream
--------------------------------------
What a catchup manifest is (and is not)
----------------------------------------
The catchup manifest is a *modified* live manifest URL (with time
parameters) for providers like Magenta and MoveTV, or a distinct URL
for providers whose catchup is served from a different origin. This
@@ -60,36 +61,16 @@ when the ABC accepts None. Pass None.
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Any, Dict, List, Optional
from ..models import DRMConfig
from ..protocols import AuthProtocol
from ..utils.logger import logger
from ._base import ManagerBase
class CatchupManager(ABC):
class CatchupManager(ManagerBase):
"""Abstract base for provider catchup 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
# ------------------------------------------------------------------
@@ -165,6 +146,25 @@ class CatchupManager(ABC):
"""
return self.auth.build_headers()
def get_catchup_segment_headers(
self,
content_id: str,
start_time: int,
end_time: Optional[int] = None,
epg_id: Optional[str] = None,
**kw: Any,
) -> Dict[str, str]:
"""
Headers for catchup segment requests.
Default: the catchup manifest headers. Mirrors
ChannelManager.get_segment_headers; override for providers whose
catchup segments need different or token-bound headers.
"""
return self.get_catchup_manifest_headers(
content_id, start_time, end_time, epg_id, **kw
)
def get_catchup_drm(
self,
content_id: str,
@@ -13,10 +13,10 @@ Public interface
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.
Four required keyword-only collaborators (see ManagerBase). 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
------------------------
@@ -27,39 +27,16 @@ failures propagate as exceptions from base.errors.
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Any, Dict, List, Optional
from ..models import Channel, DRMConfig
from ..protocols import AuthProtocol
from ..utils.logger import logger
from ._base import ManagerBase
class ChannelManager(ABC):
class ChannelManager(ManagerBase):
"""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
# ------------------------------------------------------------------
@@ -71,7 +48,8 @@ class ChannelManager(ABC):
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.
a wasted request to the wrong manager. It is a cheap, I/O-free
PRE-FILTER: False means the manager is never asked.
"""
return True
+3 -23
View File
@@ -22,37 +22,17 @@ epg_window returns (past_days, future_days). (0, 0) means no EPG support.
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import 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
from ._base import ManagerBase
class EpgManager(ABC):
class EpgManager(ManagerBase):
"""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
# ------------------------------------------------------------------
@@ -18,13 +18,17 @@ Return-value conventions
get_favorites returns [] when the user has no favorites or the provider
does not support favorites. Not an error.
add_favorite returns the created Favorite. Raises RuntimeError on
add_favorite returns the created Favorite. Raises OperationFailedError on
rejection (e.g. provider's backend refuses the operation).
remove_favorite raises KeyError if the content_id is not currently
favorited, and RuntimeError on backend failure. Deleting a
non-existent favorite is a KeyError -- consistent with
FavoritesManager's counterpart in ProviderFavoritesMixin.
remove_favorite raises ItemNotFoundError if the content_id is not currently
favorited, and OperationFailedError on backend failure. Deleting a
non-existent favorite is an ItemNotFoundError -- which is also a KeyError,
consistent with ProviderFavoritesMixin.
ItemNotFoundError is both a NotFoundError (ProviderError) and a KeyError, and
OperationFailedError is both a ProviderError and a RuntimeError, so handlers
written against the old KeyError / RuntimeError convention keep working.
Provider guidance
-----------------
@@ -36,36 +40,16 @@ them (PROGRAM, CHANNEL, CLIP, LIVE, EVENT).
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Any, List, Optional
from ..models.favorite import Favorite, FavoriteType
from ..protocols import AuthProtocol
from ..utils.logger import logger
from ._base import ManagerBase
class FavoritesManager(ABC):
class FavoritesManager(ManagerBase):
"""Abstract base for provider favorites 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
# ------------------------------------------------------------------
# Abstract
# ------------------------------------------------------------------
@@ -91,7 +75,8 @@ class FavoritesManager(ABC):
"""
Add a favorite.
Raises RuntimeError if the provider refuses the operation.
Raises OperationFailedError (also a RuntimeError) if the provider
refuses the operation.
"""
raise NotImplementedError
@@ -101,8 +86,9 @@ class FavoritesManager(ABC):
Remove a favorite.
Raises:
KeyError: if content_id is not currently favorited.
RuntimeError: on backend failure.
ItemNotFoundError: if content_id is not currently favorited
(also a KeyError).
OperationFailedError: on backend failure (also a RuntimeError).
"""
raise NotImplementedError
@@ -20,13 +20,14 @@ Return-value conventions
get_recordings returns [] when the provider has no recordings. Callers
should treat an empty list as "no recordings", not as an error.
delete_recording raises KeyError when the recording does not exist, and
ProviderError subclasses (usually ServerError / EntitlementError) on
transport or permission failures. Returning silently on a failed delete
would hide real errors.
delete_recording raises ItemNotFoundError (also a KeyError) when the
recording does not exist, and ProviderError subclasses (usually ServerError
/ EntitlementError / OperationFailedError) on transport or permission
failures. Returning silently on a failed delete would hide real errors.
schedule_recording is optional -- the ABC provides a default that raises
NotImplementedYetError. Providers that support scheduling override it.
UnsupportedOperationError, and supports_scheduling is False. Providers that
support scheduling override both.
Recording identity
------------------
@@ -38,16 +39,15 @@ that distinction is preserved.
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Any, List
from ..errors import NotImplementedYetError
from ..errors import UnsupportedOperationError
from ..models import Channel
from ..protocols import AuthProtocol
from ..utils.logger import logger
from ._base import ManagerBase
class RecordingsManager(ABC):
class RecordingsManager(ManagerBase):
"""
Abstract base for provider recordings managers.
@@ -72,29 +72,10 @@ class RecordingsManager(ABC):
Providers that need a recording's manifest should implement the
routing in their provider's get_manifest, not by adding a
get_manifest method here. See providers/simpli/provider.py for
the pattern.
get_manifest method here. See the simpliTV provider's provider.py
for the pattern.
"""
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
# ------------------------------------------------------------------
@@ -143,8 +124,9 @@ class RecordingsManager(ABC):
Delete a recording.
Raises:
KeyError: if the recording does not exist.
ProviderError: on transport / permission / server failure.
ItemNotFoundError: if the recording does not exist (also a
KeyError).
ProviderError: on transport / permission / server failure.
"""
raise NotImplementedError
@@ -152,6 +134,17 @@ class RecordingsManager(ABC):
# Concrete -- optional
# ------------------------------------------------------------------
@property
def supports_scheduling(self) -> bool:
"""
True when schedule_recording() is implemented. Default False.
Providers that override schedule_recording MUST also override this
to return True, so callers can gate the UI instead of catching
UnsupportedOperationError.
"""
return False
def schedule_recording(self, content_id: str, **kw: Any) -> Any:
"""
Schedule a recording of content_id.
@@ -161,10 +154,11 @@ class RecordingsManager(ABC):
indicating success. The ABC does not constrain the shape
because there is no shared one across providers.
Default raises NotImplementedYetError. Providers that support
scheduling override this.
Default raises UnsupportedOperationError (a NotImplementedYetError
subclass, so old handlers still match). "Not supported by this
provider" is not the same as "planned, not done yet". Providers
that support scheduling override this AND supports_scheduling.
"""
raise NotImplementedYetError(
f"{self.__class__.__name__}.schedule_recording is not "
f"implemented"
raise UnsupportedOperationError(
f"{self.__class__.__name__} does not support schedule_recording"
)
+20 -26
View File
@@ -10,6 +10,7 @@ Public interface
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_segment_headers(content_id, **kw) -> Dict[str, str] [concrete]
get_vod_drm(content_id, **kw) -> List[DRMConfig] [concrete]
content_id grammar
@@ -24,9 +25,9 @@ documents it here. The base class does not parse content_id. Examples:
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.
Four required keyword-only collaborators (see ManagerBase). No **kwargs.
Subclasses accept extra keyword-only args explicitly and call
super().__init__ with only the four required.
Return-value conventions
------------------------
@@ -37,37 +38,17 @@ propagate as exceptions from base.errors.
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import 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
from ._base import ManagerBase
class VodManager(ABC):
class VodManager(ManagerBase):
"""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
# ------------------------------------------------------------------
@@ -78,6 +59,7 @@ class VodManager(ABC):
Default: True. Override in providers whose VOD content_ids have a
distinguishable grammar (e.g. start with "details_" or "clip_").
Cheap, I/O-free PRE-FILTER: False means the manager is never asked.
"""
return True
@@ -123,6 +105,18 @@ class VodManager(ABC):
"""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.
Mirrors ChannelManager.get_segment_headers so the orchestrator can
ask any routed manager for segment headers. Override for providers
with token-bound segment URLs.
"""
return self.get_vod_manifest_headers(content_id, **kw)
def get_vod_drm(
self, content_id: str, **kw: Any
) -> List[DRMConfig]:
@@ -34,6 +34,12 @@ class Channel(Content):
f"Channel {self.name} ({self.content_id}): "
f"session_manifest=True but manifest URL is set - manifest will be ignored"
)
# Content.__post_init__ is deliberately NOT called: it raises on
# pricing/mode mismatches, and Channel's documented contract is
# "advisory warnings, the code runs" (one bad upstream channel must
# not kill the whole channel list). VodItem stays strict.
for problem in self._pricing_mode_problems():
logger.warning(f"Channel {self.name} ({self.content_id}): {problem}")
# Backward compatibility alias
@property
+36 -24
View File
@@ -1,6 +1,6 @@
from dataclasses import dataclass
from decimal import Decimal
from typing import Dict, List, Optional, Set
from typing import Dict, List, Optional
from .drm import DRMConfig
from .pricing import AccessType, PricePoint, Pricing
@@ -68,34 +68,44 @@ class Content:
# Pricing (None = UNKNOWN, NOT FREE)
pricing: Optional[Pricing] = None
def __post_init__(self):
"""Validate pricing and mode consistency."""
def _pricing_mode_problems(self) -> List[str]:
"""
Pricing-vs-mode inconsistencies, as human-readable strings.
Only combinations that are unambiguous are constrained:
PPV_LIVE -> mode must be "live"
TVOD_RENTAL / TVOD_PURCHASE -> mode must be "vod"
AVOD, PPV_REPLAY and SVOD_PPV used to be forced to "live". That made
every ad-supported on-demand item and every PPV replay (the VodItem
docstring's own "past sports event") fail at construction, because
VodItem normalises mode to "vod" before validating. Those access
types describe how content is MONETISED, not how it is delivered, so
they no longer constrain the mode.
"""
if not self.pricing:
return
# Define mode mappings
live_types: Set[AccessType] = {
AccessType.PPV_LIVE,
AccessType.PPV_REPLAY,
AccessType.SVOD_PPV,
AccessType.AVOD
}
vod_types: Set[AccessType] = {
AccessType.TVOD_RENTAL,
AccessType.TVOD_PURCHASE
}
# Check mode consistency
if self.pricing.access_type in live_types and self.mode != StreamingMode.LIVE:
raise ValueError(
f"Content '{self.name}' has {self.pricing.access_type.value} pricing "
return []
access = self.pricing.access_type
problems: List[str] = []
if access == AccessType.PPV_LIVE and self.mode != StreamingMode.LIVE:
problems.append(
f"Content '{self.name}' has {access.value} pricing "
f"but mode is '{self.mode}' (expected '{StreamingMode.LIVE}')"
)
elif self.pricing.access_type in vod_types and self.mode != StreamingMode.VOD:
raise ValueError(
f"Content '{self.name}' has {self.pricing.access_type.value} pricing "
elif (
access in (AccessType.TVOD_RENTAL, AccessType.TVOD_PURCHASE)
and self.mode != StreamingMode.VOD
):
problems.append(
f"Content '{self.name}' has {access.value} pricing "
f"but mode is '{self.mode}' (expected '{StreamingMode.VOD}')"
)
return problems
def __post_init__(self):
"""Validate pricing and mode consistency (strict: raises)."""
problems = self._pricing_mode_problems()
if problems:
raise ValueError(problems[0])
# --- Pricing Properties ---
@@ -226,6 +236,8 @@ class Content:
"ContentType": self.content_type,
"Country": self.country,
"Language": self.language,
"Description": self.description,
"Genre": self.genre,
"StreamingFormat": self.streaming_format,
"LicenseUrl": self.license_url,
"CertificateUrl": self.certificate_url,
@@ -4,8 +4,8 @@ DRM Configuration Model
Main configuration class that combines DRM system, PSSH data, and license config.
"""
from dataclasses import dataclass
from typing import Optional
from dataclasses import dataclass, replace
from typing import Dict, Optional, Sequence
from .drm_systems import DRMSystem
from .license_config import LicenseConfig
@@ -213,4 +213,66 @@ class DRMConfig:
has_license = "with license" if self.license else "no license"
return (
f"<DRMConfig({self.system.name}, priority={self.priority}, {has_license})>"
)
)
# ---------------------------------------------------------------------------
# Multi-DRM helpers
# ---------------------------------------------------------------------------
def validate_drm_set(configs: Sequence[DRMConfig]) -> None:
"""
Validate a set of DRMConfig objects that will be sent to ISA together.
Checks each config (DRMConfig.validate) and that no two configs share a
priority. The factories (create_widevine, create_playready, ...) all
default to priority=1, so a naive [widevine, playready] list is INVALID
until priorities are assigned -- see merge_drm_configs(auto_priority=True).
Raises:
LicenseConfigError: on any invalid config or duplicate priority.
"""
seen: Dict[int, DRMConfig] = {}
for cfg in configs:
cfg.validate()
if cfg.priority in seen:
raise LicenseConfigError(
f"{cfg.system.name} and {seen[cfg.priority].system.name} "
f"share priority {cfg.priority}; each DRM needs a unique "
f"priority (lower number = higher priority)."
)
seen[cfg.priority] = cfg
def merge_drm_configs(
configs: Sequence[DRMConfig], *, auto_priority: bool = False
) -> dict:
"""
Merge several DRMConfig objects into ONE inputstream.adaptive.drm dict.
Args:
configs: DRM configs in preference order (first = most preferred).
auto_priority: Renumber priorities 1..n in the given order (on
copies; the inputs are not mutated). Without it the configs
must already carry unique priorities.
Raises:
LicenseConfigError: invalid config, duplicate priority, or the same
DRM system listed twice. (A config with pre_init_data must keep
priority=1; auto_priority will trip that rule if it is not first.)
"""
items = list(configs)
if auto_priority:
items = [replace(c, priority=i) for i, c in enumerate(items, start=1)]
validate_drm_set(items)
merged: dict = {}
for cfg in items:
entry = cfg.to_dict()
clash = set(entry) & set(merged)
if clash:
raise LicenseConfigError(
f"DRM system listed twice: {sorted(clash)}"
)
merged.update(entry)
return merged
@@ -2,12 +2,26 @@
DRM-specific Exception Classes
Custom exceptions for better error handling and debugging in DRM operations.
All of them descend from DRMError, which is a ConfigurationError and
therefore a ProviderError (base/errors.py). A caller that handles
``ProviderError`` now also catches DRM configuration problems; before this
change DRMError derived from plain Exception and slipped past every
``except ProviderError`` handler.
"""
from ...errors import ConfigurationError
class DRMError(Exception):
"""Base exception for all DRM-related errors"""
pass
class DRMError(ConfigurationError):
"""Base exception for all DRM-related errors.
``message`` is optional so call sites that raise a bare
``InvalidPSSHError()`` keep working (ProviderError itself requires one).
"""
def __init__(self, message: str = "", **kw) -> None:
super().__init__(message, **kw)
class InvalidPSSHError(DRMError):
@@ -5,12 +5,13 @@ Data classes for DRM license configuration, including server URLs,
certificates, headers, and unwrapper parameters.
"""
import base64
import json
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, Optional, Union
from urllib.parse import urlencode, unquote
from urllib.parse import quote, urlencode
from .utils import safe_base64_decode, safe_base64_encode, normalize_key_id
from .exceptions import LicenseConfigError
@@ -149,15 +150,17 @@ class LicenseConfig:
norm_kid = normalize_key_id(kid)
norm_key = key.lower().replace("-", "")
normalized_keyids[norm_kid] = norm_key
except Exception:
pass
except Exception as e:
# Previously swallowed: a bad pair vanished silently,
# keyids ended up empty and validate() still passed.
raise LicenseConfigError(
f"Invalid ClearKey entry (kid={kid!r}): {e}"
) from e
self.keyids = normalized_keyids
@staticmethod
def _is_base64(s: str) -> bool:
"""Check if string appears to be base64 encoded."""
import base64
import re
# Base64 pattern: only valid chars, length multiple of 4
if not re.match(r'^[A-Za-z0-9+/]*={0,2}$', s):
return False
@@ -167,6 +170,21 @@ class LicenseConfig:
except Exception:
return False
@staticmethod
def _split_flags(value: Optional[str]) -> list:
"""Split a comma-separated flag string ("base64,urlenc") into tokens."""
return [t.strip().lower() for t in (value or "").split(",") if t.strip()]
@classmethod
def _validate_flag_list(cls, name: str, value: Optional[str], enum_cls) -> None:
allowed = {m.value for m in enum_cls}
for token in cls._split_flags(value):
if token not in allowed:
raise LicenseConfigError(
f"Invalid {name} flag '{token}'. "
f"Allowed: {', '.join(sorted(allowed))}"
)
def validate(self) -> None:
"""
Validate the license configuration.
@@ -201,6 +219,17 @@ class LicenseConfig:
"Construct via the normal dataclass constructor to ensure normalization."
)
self._validate_flag_list("wrapper", self.wrapper, WrapperType)
self._validate_flag_list("unwrapper", self.unwrapper, UnwrapperType)
if (
set(self._split_flags(self.unwrapper)) & {"json", "xml"}
and self.unwrapper_params is None
):
raise LicenseConfigError(
"unwrapper_params is required when unwrapper includes "
"'json' or 'xml'."
)
if self.keyids:
for kid, key in self.keyids.items():
if len(kid) != 32:
@@ -272,7 +301,7 @@ class LicenseConfig:
# Case 1: Already a dict
if isinstance(headers, dict):
return urlencode(headers)
return self._encode_headers(headers)
# Case 2: String input
if isinstance(headers, str):
@@ -285,21 +314,23 @@ class LicenseConfig:
try:
header_dict = json.loads(headers)
if isinstance(header_dict, dict):
return urlencode(header_dict)
return self._encode_headers(header_dict)
except json.JSONDecodeError as e:
raise LicenseConfigError(
f"Invalid JSON in req_headers: {e}"
) from e
# Already URL-encoded: contains '=' and at least one '&' or ';'
if "=" in headers and ("&" in headers or ";" in headers):
# "Key: Value" plain text: a ':' appears before any '='.
if self._looks_plain(headers):
return self._parse_plain_headers(headers)
# URL-encoded "k=v[&k=v...]". A SINGLE pair is valid too (the
# class docstring's own example is one pair, which the old
# '&'/';' requirement rejected).
if "=" in headers:
self._validate_urlencoded_headers(headers)
return headers
# "Key: Value" plain-text format (no '=' present)
if ":" in headers and "=" not in headers:
return self._parse_plain_headers(headers)
raise LicenseConfigError(
f"Invalid req_headers format: '{headers}'. "
f"Must be a dict, JSON string, URL-encoded string, "
@@ -310,6 +341,25 @@ class LicenseConfig:
f"req_headers must be dict, string, or None, got {type(headers).__name__}"
)
@staticmethod
def _looks_plain(headers: str) -> bool:
"""True when ``headers`` is "Key: Value" text rather than "k=v"."""
colon = headers.find(":")
equals = headers.find("=")
return colon != -1 and (equals == -1 or colon < equals)
@staticmethod
def _encode_headers(header_dict: Dict[str, str]) -> str:
"""
URL-encode a header dict.
quote_via=quote with safe="" gives "%20" for spaces and "%2F" for
"/" (as documented), where the default quote_plus wrote "+" for
spaces. TODO(device-verify): confirm ISA decodes "+" and "%20" alike;
"%20" is the unambiguous choice either way.
"""
return urlencode(header_dict, quote_via=quote, safe="")
@staticmethod
def _validate_urlencoded_headers(headers: str) -> None:
"""
@@ -334,20 +384,22 @@ class LicenseConfig:
key, value = pair.split("=", 1)
if not key.strip():
raise LicenseConfigError(f"Empty header key in pair: '{pair}'")
try:
unquote(value) # Raises if the percent-encoding is broken
except Exception as e:
# unquote() never raises, so the old try/except validated nothing.
if re.search(r"%(?![0-9A-Fa-f]{2})", pair):
raise LicenseConfigError(
f"Header value is not properly URL-encoded: '{value}'"
) from e
f"Header value has a malformed percent-escape: '{pair}'"
)
@staticmethod
def _parse_plain_headers(headers: str) -> str:
"""
Convert ``"Key: Value"`` lines to a URL-encoded string.
Lines may be separated by newlines, carriage-returns, semicolons,
or commas. Blank lines are silently skipped.
Headers are separated by newlines / carriage-returns, or by a ';' or
',' that is followed by another "Name:" token. A comma or semicolon
inside a value ("Mozilla/5.0 (KHTML, like Gecko)", "text/html;q=0.9")
does NOT split. Blank lines are skipped; a header with an empty value
is kept (it used to be dropped silently).
Example::
@@ -364,7 +416,7 @@ class LicenseConfig:
LicenseConfigError: If any non-blank line lacks a ``':'``.
"""
header_dict: Dict[str, str] = {}
for line in re.split(r"[\n\r;,]+", headers):
for line in re.split(r"[\n\r]+|[;,]\s*(?=[A-Za-z0-9_\-]+\s*:)", headers):
line = line.strip()
if not line:
continue
@@ -375,9 +427,10 @@ class LicenseConfig:
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if key and value:
header_dict[key] = value
return urlencode(header_dict)
if not key:
raise LicenseConfigError(f"Empty header name in line: '{line}'")
header_dict[key] = value
return LicenseConfig._encode_headers(header_dict)
def to_dict(self) -> dict:
"""
+18 -2
View File
@@ -19,6 +19,22 @@ class AccessType(Enum):
SVOD_PPV = "svod_ppv" # Subscription + extra PPV surcharge
def _as_comparable(now: datetime, bound: datetime) -> datetime:
"""
Make ``now`` comparable with ``bound``.
Python refuses to compare naive and timezone-aware datetimes. Upstream
APIs usually deliver aware valid_from/valid_until values, while the
default "now" was naive, so is_active() raised TypeError. A naive value
is interpreted as local wall-clock time.
"""
if (now.tzinfo is None) == (bound.tzinfo is None):
return now
if bound.tzinfo is not None:
return now.astimezone() # naive now -> aware (local)
return now.astimezone().replace(tzinfo=None) # aware now -> naive local
@dataclass
class PricePoint:
"""A specific price for a region/quality/time period."""
@@ -42,9 +58,9 @@ class PricePoint:
def is_active(self, at: Optional[datetime] = None) -> bool:
"""Check if this price point is currently valid."""
now = at or datetime.now()
if self.valid_from and now < self.valid_from:
if self.valid_from and _as_comparable(now, self.valid_from) < self.valid_from:
return False
if self.valid_until and now > self.valid_until:
if self.valid_until and _as_comparable(now, self.valid_until) > self.valid_until:
return False
return True
+6 -4
View File
@@ -124,8 +124,10 @@ class VodCategory:
# of reconstructing it from content_id, which loses query params.
fetch_url: Optional[str] = None
# Cached slug (computed lazily if not set)
_slug: Optional[str] = field(default=None, repr=False)
# Cached slug (computed lazily). init=False / compare=False: it is a
# cache, not state -- two equal categories must compare equal whether or
# not .slug was accessed, and it must not be an __init__ parameter.
_slug: Optional[str] = field(default=None, init=False, repr=False, compare=False)
def __post_init__(self):
"""Validate and clean up required fields."""
@@ -218,8 +220,8 @@ class VodItem(Content):
# False → full broadcast recording (videoType == "STANDALONE_EVENT" etc.)
is_highlight: bool = False
# Cached slug
_slug: Optional[str] = field(default=None, repr=False)
# Cached slug (see VodCategory._slug)
_slug: Optional[str] = field(default=None, init=False, repr=False, compare=False)
def __post_init__(self):
"""Validate and clean up required fields."""
@@ -3,20 +3,62 @@
Core provider registry handling discovery, metadata, and lifecycle management.
"""
import threading
from typing import Any, Dict, List, Optional
from .provider import StreamingProvider
from .utils.logger import logger
_CAPABILITY_NAMES = (
"channels", "vod", "epg", "recordings", "favorites", "bookmarks",
"catchup", "drm",
)
def _collect_capabilities(instance: Optional[StreamingProvider]) -> Optional[Dict[str, bool]]:
"""
Read the derived ``implements_*`` flags of a live provider instance.
Capabilities are instance-level (they come from which managers the
provider wired), so they are only known once the provider exists, i.e.
for ENABLED providers. Returns None when there is no instance or the
provider exposes no boolean flags (e.g. an old-style provider).
"""
if instance is None:
return None
caps: Dict[str, bool] = {}
for name in _CAPABILITY_NAMES:
try:
value = getattr(instance, f"implements_{name}")
except Exception: # a property may raise on a half-built provider
continue
if isinstance(value, bool):
caps[name] = value
return caps or None
class ProviderMetadata:
"""Metadata for a provider instance with lazy initialization."""
def __init__(self, plugin_class, country: str, enabled: bool = False):
def __init__(
self,
plugin_class,
country: str,
enabled: bool = False,
instance_name: Optional[str] = None,
):
self.plugin_class = plugin_class
self.country = country.lower()
self.enabled = enabled
self.instance: Optional[StreamingProvider] = None
# The registry key this metadata is stored under. When given, it
# becomes ``name`` so to_dict()["name"] always resolves in
# get_provider(); otherwise the name is derived from the class name
# and the two can drift apart.
self._instance_name = instance_name
# Guards lazy create/destroy: two threads asking for the same
# provider must not build (and authenticate) two instances.
self._lock = threading.RLock()
self._extract_metadata()
def _extract_metadata(self):
@@ -55,27 +97,36 @@ class ProviderMetadata:
for auth_type in self.supported_auth_types
)
if self._instance_name:
self.name = self._instance_name
def create_instance(self) -> Optional[StreamingProvider]:
"""Lazily create provider instance if enabled"""
if not self.enabled:
return None
if self.instance is None:
try:
logger.info(f"Creating instance for provider: {self.name}")
self.instance = self.plugin_class(country=self.country)
logger.debug(f"Successfully created instance for {self.name}")
except Exception as e:
logger.error(f"Failed to create instance for {self.name}: {e}")
self.instance = None
with self._lock:
if self.instance is None:
try:
logger.info(f"Creating instance for provider: {self.name}")
self.instance = self.plugin_class(country=self.country)
logger.debug(f"Successfully created instance for {self.name}")
except Exception:
# logger.exception keeps the traceback: a missing
# provider_name, a bad manager wiring (ConfigurationError)
# or an import error otherwise shows up as one cryptic
# line and the provider silently vanishes from the UI.
logger.exception(f"Failed to create instance for {self.name}")
self.instance = None
return self.instance
return self.instance
def destroy_instance(self):
"""Clean up provider instance"""
if self.instance:
logger.debug(f"Destroying instance for provider: {self.name}")
self.instance = None
with self._lock:
if self.instance:
logger.debug(f"Destroying instance for provider: {self.name}")
self.instance = None
def set_enabled(self, enabled: bool):
"""Update enabled status and manage instance accordingly"""
@@ -99,6 +150,7 @@ class ProviderMetadata:
"logo": self.logo,
"is_multi_country": self.is_multi_country,
"supported_countries": self.supported_countries,
"capabilities": _collect_capabilities(self.instance),
}
@@ -125,6 +177,7 @@ class M3UGroupMetadata:
self.country = country.lower()
self.enabled = enabled
self.instance: Optional[StreamingProvider] = None
self._lock = threading.RLock()
# Extract metadata from class
self._extract_metadata()
@@ -161,28 +214,30 @@ class M3UGroupMetadata:
if not self.enabled:
return None
if self.instance is None:
try:
logger.info(f"Creating M3U group instance: {self.name} (group: {self.group})")
with self._lock:
if self.instance is None:
try:
logger.info(f"Creating M3U group instance: {self.name} (group: {self.group})")
# Create instance with group filter - __init__ will handle proxy setup
self.instance = self.plugin_class(
country=self.country,
group_filter=self.group
)
# Create instance with group filter - __init__ will handle proxy setup
self.instance = self.plugin_class(
country=self.country,
group_filter=self.group
)
logger.debug(f"Successfully created instance for M3U group: {self.name}")
except Exception as e:
logger.error(f"Failed to create M3U group instance {self.name}: {e}")
self.instance = None
logger.debug(f"Successfully created instance for M3U group: {self.name}")
except Exception:
logger.exception(f"Failed to create M3U group instance {self.name}")
self.instance = None
return self.instance
return self.instance
def destroy_instance(self):
"""Clean up provider instance"""
if self.instance:
logger.debug(f"Destroying M3U group instance: {self.name}")
self.instance = None
with self._lock:
if self.instance:
logger.debug(f"Destroying M3U group instance: {self.name}")
self.instance = None
def set_enabled(self, enabled: bool):
"""Update enabled status and manage instance accordingly"""
@@ -206,6 +261,7 @@ class M3UGroupMetadata:
"logo": self.logo,
"is_multi_country": self.is_multi_country,
"supported_countries": self.supported_countries,
"capabilities": _collect_capabilities(self.instance),
# M3U-specific fields
"group": self.group,
"type": "m3u_group",
@@ -260,7 +316,9 @@ class ProviderRegistry:
instance_name = f"{plugin_name}_{country}"
enabled = self._is_provider_enabled(plugin_name, country)
metadata = ProviderMetadata(plugin_class, country, enabled)
metadata = ProviderMetadata(
plugin_class, country, enabled, instance_name=instance_name
)
self.provider_metadata[instance_name] = metadata
discovered.append(instance_name)
@@ -272,7 +330,9 @@ class ProviderRegistry:
instance_name = plugin_name
enabled = self._is_provider_enabled(plugin_name)
metadata = ProviderMetadata(plugin_class, default_country, enabled)
metadata = ProviderMetadata(
plugin_class, default_country, enabled, instance_name=instance_name
)
self.provider_metadata[instance_name] = metadata
discovered.append(instance_name)
@@ -405,6 +465,9 @@ class ProviderRegistry:
return False
try:
# Drop the registry's reference first: if re-creation fails we
# must not keep serving the instance the metadata already forgot.
self.providers.pop(provider_name, None)
metadata.destroy_instance()
new_instance = metadata.create_instance()
if new_instance:
+2 -2
View File
@@ -22,6 +22,7 @@ from dataclasses import dataclass, field
from typing import Iterator, List, Optional, Union
from .models.vod import VodCategory, VodItem
from .utils.logger import logger
VodEntry = Union[VodCategory, VodItem]
@@ -102,8 +103,7 @@ def normalize_vod_result(result) -> VodPage:
)
if isinstance(result, list):
return VodPage(entries=result)
import logging
logging.getLogger(__name__).warning(
logger.warning(
f"normalize_vod_result: unexpected type {type(result).__name__}; "
f"returning empty page"
)