From 3147ae85add57479ee7556b885032252720a7dda Mon Sep 17 00:00:00 2001 From: Nirvana Date: Fri, 9 Oct 2026 12:38:39 +0200 Subject: [PATCH] migrate allente to new structure --- .../base/utils/transport.py | 27 ++ .../providers/allente/channel_manager.py | 225 +++++++--- .../providers/allente/constants.py | 19 + .../providers/allente/provider.py | 423 +++++------------- .../providers/allente/session.py | 238 ++++++++++ .../providers/simpli/recordings_manager.py | 3 +- 6 files changed, 556 insertions(+), 379 deletions(-) create mode 100644 lib/streaming_providers/base/utils/transport.py create mode 100644 lib/streaming_providers/providers/allente/session.py diff --git a/lib/streaming_providers/base/utils/transport.py b/lib/streaming_providers/base/utils/transport.py new file mode 100644 index 0000000..251aa74 --- /dev/null +++ b/lib/streaming_providers/base/utils/transport.py @@ -0,0 +1,27 @@ +# streaming_providers/base/utils/transport.py +""" +transport_errors -- shared wrapper for provider network calls. + +Wraps *unexpected* failures (network, bad JSON, malformed payloads) in +ServerError and lets typed provider errors (AuthError, RateLimitError, +GeoBlockError, ...) through untouched: callers rely on those to refresh +tokens or back off, so they must never be flattened into a generic error. + +simpli keeps its own identical copy in providers/simpli/helpers.py until it +is switched over to this one. +""" + +from contextlib import contextmanager + +from ..errors import ProviderError, ServerError + + +@contextmanager +def transport_errors(what: str, provider: str = ""): + try: + yield + except ProviderError: + raise + except Exception as e: + prefix = f"{provider}: " if provider else "" + raise ServerError(f"{prefix}{what} failed: {e}") from e diff --git a/lib/streaming_providers/providers/allente/channel_manager.py b/lib/streaming_providers/providers/allente/channel_manager.py index 63373a4..87e1474 100644 --- a/lib/streaming_providers/providers/allente/channel_manager.py +++ b/lib/streaming_providers/providers/allente/channel_manager.py @@ -5,52 +5,74 @@ Allente Channel Manager. Handles: * Channel list (GET /v1/channels) — DASH only * Playout (GET /v1/playout/channel/{id}) + * Widevine DRM (folded: manifest and DRM share ONE playout response) The stream-session endpoints (/v1/stream/session/...) are NOT used in v1. Captured logs suggest direct streaming works without them; if the 60-minute continuous playback test fails, revisit in v2 (and reintroduce a persistent deviceId). + +Collaborators (ChannelManager contract) +--------------------------------------- + auth -> AllenteSession (AuthProtocol adapter; also exposes + entitlement_tag and profile) + config -> AllenteConfig (the ONE shared instance) + +Error contract +-------------- +Auth / geo failures raise AuthError / GeoBlockError (from the session), +network and malformed responses raise ServerError. Nothing is swallowed +into [] / None any more -- the previous provider logged and returned an +empty result. A channel id every manager would accept is "handled": the +default handles_content_id() (True) is correct, all ids are channel ids. + +Playout cache +------------- +get_manifest and get_drm are separate calls for the same channel; the +5-second cache lets them share one playout request. It is keyed by channel +id and cleared by the provider when credentials change. """ -from typing import List, Optional +import time +from typing import Any, Dict, List, Optional, Tuple -from ...base.models import StreamingChannel +from ...base.managers import ChannelManager +from ...base.models import Channel, DRMConfig from ...base.utils.logger import logger +from ...base.utils.transport import transport_errors from .constants import AllenteDefaults +from .drm import create_allente_widevine_config from .models import AllenteChannel, AllentePlayoutInfo +_PROVIDER = "Allente" -class AllenteChannelManager: + +class AllenteChannelManager(ChannelManager): """Fetches and resolves linear channels for Allente.""" - def __init__(self, provider): - self._provider = provider - - # ------------------------------------------------------------------ - # Convenience accessors - # ------------------------------------------------------------------ - @property - def config(self): - return self._provider.provider_config - - @property - def http(self): - return self._provider.http_manager - - def _zulu_headers(self) -> dict: - return self.config.zulu_headers(self._provider.bearer_token) + def __init__( + self, + *, + http_manager: Any, + auth: Any, + country: str, + config: Any, + playout_cache: Optional[Dict[str, Tuple[AllentePlayoutInfo, float]]] = None, + ): + super().__init__( + http_manager=http_manager, auth=auth, country=country, config=config + ) + # channel_id -> (info, timestamp). Provider-owned dict, borrowed. + self._playout_cache = playout_cache if playout_cache is not None else {} # ------------------------------------------------------------------ # Channel list # ------------------------------------------------------------------ - def get_channels(self) -> List[AllenteChannel]: - """Fetch the user's channels from /v1/channels (DASH only).""" - ent_tag = self._provider.entitlement_tag - profile = self._provider.get_profile() - if not ent_tag or not profile: - logger.error("Allente: missing entitlement_tag or profile for channels") - return [] + def get_channels(self, **kw: Any) -> List[Channel]: + """The user's playable channels (DASH + Widevine), [] if none.""" + self.auth.require() + profile = self.auth.profile params = { # DASH only: playout always requests DASH, so MSS-only channels # would appear in the UI but fail at playback time. @@ -58,33 +80,32 @@ class AllenteChannelManager: "kids": str(profile.kids).lower(), "parentalLevel": str(profile.parental_level), "profileId": profile.id, - "entitlementTag": ent_tag, + "entitlementTag": self.auth.entitlement_tag, } - # No raise_for_status(): HTTPManager raises for all 4xx/5xx - # internally; any response reaching here is 1xx–3xx. - resp = self.http.get( - AllenteDefaults.ZULU_CHANNELS, - params=params, - headers=self._zulu_headers(), - operation="api", - ) - data = resp.json() - channels = [ - AllenteChannel.from_api_response(c) - for c in data.get("channels", []) - ] + with transport_errors("get_channels", _PROVIDER): + # No raise_for_status(): HTTPManager raises for all 4xx/5xx + # internally; any response reaching here is 1xx–3xx. + resp = self.http_manager.get( + AllenteDefaults.ZULU_CHANNELS, + params=params, + headers=self.auth.build_headers(), + operation="api", + ) + raw = resp.json().get("channels", []) + + channels: List[AllenteChannel] = [] + for entry in raw: + # Strict parser, tolerant caller: one malformed entry must not + # take the whole list down (see AllenteChannel). + try: + channels.append(AllenteChannel.from_api_response(entry)) + except (KeyError, TypeError, AttributeError) as exc: + logger.warning(f"Allente: skipping malformed channel entry: {exc!r}") logger.info(f"Allente: fetched {len(channels)} channels") - return channels - def get_channels_as_streaming_channels(self) -> List[StreamingChannel]: - """ - Convert channels for the UI, dropping anything we cannot play. - - Safety net: even though the list is requested DASH-only, the server - could still return MSS or non-Widevine entries. Filter them out - here rather than showing channels that die on click. - """ - channels = self.get_channels() + # Safety net: even though the list is requested DASH-only, the + # server could still return MSS or non-Widevine entries. Filter + # them out rather than showing channels that die on click. playable = [ c for c in channels if c.stream_type == "DASH" and c.stream_drm_type == "Widevine" @@ -94,27 +115,97 @@ class AllenteChannelManager: logger.debug( f"Allente: filtered out {dropped} non-DASH/non-Widevine channels" ) - return [ - c.to_streaming_channel(self._provider.provider_name) - for c in playable - ] + return [c.to_streaming_channel(AllenteDefaults.PROVIDER_NAME) for c in playable] # ------------------------------------------------------------------ - # Playout + # Playout (shared by manifest and DRM) # ------------------------------------------------------------------ - def resolve_playout(self, channel_id: str) -> Optional[AllentePlayoutInfo]: - """Ask Zulu for the playout info for a channel.""" + + def clear_playout_cache(self) -> None: + """Drop cached playout info (streamIds may be user/token-specific).""" + self._playout_cache.clear() + + def _playout(self, content_id: str) -> AllentePlayoutInfo: + now = time.time() + cached = self._playout_cache.get(content_id) + if cached and (now - cached[1]) < AllenteDefaults.PLAYOUT_CACHE_TTL: + return cached[0] + + self.auth.require() params = { "streamType": self.config.stream_type, - "entitlementTag": self._provider.entitlement_tag, + "entitlementTag": self.auth.entitlement_tag, "widevineLevel": self.config.widevine_level, } - url = f"{AllenteDefaults.ZULU_PLAYOUT_CHANNEL}/{channel_id}" - # No raise_for_status(): see get_channels(). - resp = self.http.get( - url, - params=params, - headers=self._zulu_headers(), - operation="api", - ) - return AllentePlayoutInfo.from_api_response(resp.json()) \ No newline at end of file + url = f"{AllenteDefaults.ZULU_PLAYOUT_CHANNEL}/{content_id}" + with transport_errors(f"playout for {content_id}", _PROVIDER): + # No raise_for_status(): see get_channels(). + resp = self.http_manager.get( + url, + params=params, + headers=self.auth.build_headers(), + operation="api", + ) + # ValueError for a payload without stream.url / stream.streamId + # becomes a ServerError here. + info = AllentePlayoutInfo.from_api_response(resp.json()) + self._playout_cache[content_id] = (info, now) + return info + + # ------------------------------------------------------------------ + # Manifest + # ------------------------------------------------------------------ + + def get_channel_manifest(self, content_id: str, **kw: Any) -> Optional[str]: + """The MPD URL for a channel (content_id = channel ID).""" + return self._playout(content_id).stream_url + + def get_channel_manifest_headers( + self, content_id: str, **kw: Any + ) -> Dict[str, str]: + """Headers for the MPD fetch: origin / referer / UA (Akamai gate).""" + return self.config.stream_headers() + + # get_segment_headers: ABC default = manifest headers (what the + # previous provider did). + + # ------------------------------------------------------------------ + # DRM (Widevine via Zulu), folded into this manager + # ------------------------------------------------------------------ + + def get_channel_drm(self, content_id: str, **kw: Any) -> List[DRMConfig]: + """ + Widevine DRM configuration for a channel. + + drm_variant (and other backend kwargs) are accepted and ignored in + v1 -- the Widevine security level comes from config.widevine_level. + Mapping variants to levels (software -> L3, hardware -> L1) is a + v1.1 candidate and would also require keying the playout cache by + level, since widevineLevel is a playout request parameter. + + NOTE: the license config embeds the CURRENT bearer token, and ISA + reuses it for the whole playback session. Continuous playback + across token expiry is not supported in v1. + + Returns [] for non-Widevine channels (unsupported in v1). A broken + DRM configuration raises (LicenseConfigError is a ConfigurationError) + instead of being logged away. + """ + variant = kw.get("drm_variant") + if variant: + logger.debug(f"Allente: get_drm drm_variant={variant!r} — ignored in v1") + + playout = self._playout(content_id) + if playout.drm_type != "Widevine": + logger.warning( + f"Allente: channel {content_id} uses {playout.drm_type}, " + f"not Widevine. Unencrypted/other-DRM playback is not " + f"supported in v1." + ) + return [] + + return [create_allente_widevine_config( + cfg=self.config, + bearer_token=self.auth.get_access_token(), + stream_id=playout.stream_id, + )] \ No newline at end of file diff --git a/lib/streaming_providers/providers/allente/constants.py b/lib/streaming_providers/providers/allente/constants.py index f9adc08..d14aa09 100644 --- a/lib/streaming_providers/providers/allente/constants.py +++ b/lib/streaming_providers/providers/allente/constants.py @@ -23,6 +23,9 @@ from ..globals import get_user_agent class AllenteDefaults: """Default values for the Allente provider.""" + # Registry key / directory name / credential + session key. + PROVIDER_NAME = "allente" + ALLENTE_LOGO = "https://imgix-images-cdn.3ready.cc/e2dbff8f-a707-4d34-b7f5-856e45193f3c/logo_white_big.png" # ------------------------------------------------------------------ @@ -200,6 +203,22 @@ class AllenteConfig: # SE-only in v1; the fallback keeps old persisted configs working. return AllenteDefaults.CONTENT_DOMAIN_BY_COUNTRY.get(self.country, "DTH-SE") + def stream_headers(self) -> dict: + """ + Headers for the MPD and segment fetches (CDN, not Zulu). + + Akamai fronts stream-live-01.allente.tv and rejects requests + without a whitelisted origin/referer (it answers with an "Access + Denied" HTML page). The CDN advertises access-control-allow-origin: * + for CORS, but that is a browser-level policy -- the actual gate is + enforced at the edge based on these headers. + """ + return { + "origin": AllenteDefaults.TV_WEB_ORIGIN, + "referer": AllenteDefaults.TV_WEB_REFERER, + "user-agent": self.user_agent, + } + def zulu_headers(self, access_token: Optional[str] = None) -> dict: return AllenteHeaders.zulu_headers( access_token=access_token, diff --git a/lib/streaming_providers/providers/allente/provider.py b/lib/streaming_providers/providers/allente/provider.py index c2aba36..529d6fd 100644 --- a/lib/streaming_providers/providers/allente/provider.py +++ b/lib/streaming_providers/providers/allente/provider.py @@ -2,54 +2,51 @@ """ Allente streaming provider (SE only in v1). -Public API (implemented here): - * get_channels() -> List[StreamingChannel] - * get_manifest(channel_id) -> MPD URL (abstract in base — required) - * get_drm(channel_id, ...) -> List[DRMConfig] (Widevine via Zulu) - * set_user_credentials(...) -> bool (Kodi settings UI) - * get_last_auth_error() -> Optional[Exception] (direct callers) - * get_auth_details(context) -> Dict (AuthStatus UI, via the auth mixin) +Owns the shared resources (config, http_manager, authenticator, session, +playout cache) and builds the managers; capability flags, routing and the +manifest / DRM / header delegation are ManagedProvider's. -Inherited from StreamingProvider (verified against base source — do not -re-implement): - * get_manifest_headers() / get_segment_headers() → overridden to send - origin/referer/UA. Akamai's edge rejects - requests without them (Access Denied HTML page). - The response advertises access-control-allow-origin: - * for CORS, but the actual gate is enforced at the edge. - * get_events()/EPG/VOD/etc. -> mixin defaults (empty) — out of v1 scope. - * to_output_format()/to_json()-> consume self.channels, populated by - get_channels(). - * enrich_channel_data() -> base returns None. Designated pre-playback - prefetch hook (playout + DRM). If - integration testing shows the player - calls it, implement via - _resolve_playout_cached() and patch - channel.manifest with the playout URL. +Manager wiring +-------------- -Registry integration (verified against provider_registry.py): - * ProviderMetadata derives plugin_name "allente" from the class name — + channels -> AllenteChannelManager (channel list, playout, folded + Widevine DRM) + everything else -> None (no VOD, EPG, recordings, + favorites, bookmarks, catchup) + + DRM_IN_MANAGERS = True manifest and DRM share one playout response + epg_window == (0, 0) no EPG in v1 (ManagedProvider default) + +Auth model: lazy with one opportunistic eager attempt. + * __init__ attempts auth ONLY if credentials are already stored. + * The managers gate on AllenteSession.require(), which raises typed + errors (AuthError / GeoBlockError / ServerError). The previous + provider logged and returned [] / None; get_channels() now raises when + the account cannot log in. (To restore the old behaviour override + get_channels with a try/except ProviderError returning [].) + * No network I/O in __init__ when credentials are not configured. + +Public API kept from the previous provider (settings UI, direct callers): + * set_user_credentials(...) -> bool + * get_last_auth_error() -> Optional[Exception] + * get_auth_details(context) -> Dict (AuthStatus UI, via the auth mixin) + * get_profile(), bearer_token, entitlement_tag, channel_manager, + _ensure_authenticated() -> thin compatibility delegates + +Registry integration: + * ProviderMetadata derives plugin_name "allente" from the class name, matching provider_name and the CredentialManager/SessionManager keys. * For a single-country provider with exactly one supported country, _extract_metadata() OVERRIDES the passed-in country with - SUPPORTED_COUNTRIES[0] — VERBATIM, i.e. UPPERCASE "SE" (a framework - quirk; see the country normalization in __init__). create_instance() - additionally try/excepts construction, so enumeration never crashes. + SUPPORTED_COUNTRIES[0] — VERBATIM, i.e. UPPERCASE "SE". COUNTRY CASE (important): * The framework's storage managers (CredentialManager, SessionManager, ProxyConfigManager) key everything by LOWERCASE country ("se"). - * The registry constructs us with UPPERCASE "SE" (see above). + * The registry constructs us with UPPERCASE "SE". * __init__ therefore normalizes self.country to lowercase, so the - ProxyConfigManager lookup in _setup_http_manager and the - AuthContext.get_credentials() lookup in the auth mixin hit the same - keys the managers store under. Without this, a country-specific - proxy is silently missed and stored credentials are not found. - -Auth model: lazy with one opportunistic eager attempt. - * __init__ attempts auth ONLY if credentials are already stored. - * Every public method gates on _ensure_authenticated(). - * No network I/O in __init__ when credentials are not configured. + ProxyConfigManager lookup in _setup_http_manager and the credential + lookup in the auth mixin hit the same keys the managers store under. Known v1 limitation: the DRM config embeds the bearer token at get_drm() time, and inputstream.adaptive caches it for the whole playback session. @@ -57,21 +54,24 @@ Continuous playback beyond Zulu token expiry is NOT supported; a fresh channel zap after refresh works. """ -import time -from typing import ClassVar, Dict, List, Optional, Tuple +from typing import ClassVar, Dict, List, Optional -from ...base.models import DRMConfig, StreamingChannel +from ...base.managed_provider import ManagedProvider from ...base.models.proxy_models import ProxyConfig -from ...base.provider import StreamingProvider from ...base.utils.logger import logger -from .auth import AllenteAuthenticator, AllenteOTPRequiredError +from .auth import AllenteAuthenticator from .channel_manager import AllenteChannelManager from .constants import AllenteConfig, AllenteDefaults -from .drm import create_allente_widevine_config -from .models import AllentePlayoutInfo, AllenteProfile, AllenteUserCredentials +from .models import AllenteProfile, AllenteUserCredentials +from .session import AllenteSession + +# Only the provider is public: provider discovery takes the first +# StreamingProvider subclass it finds in the package namespace, and +# ManagedProvider must never be that one. +__all__ = ["AllenteProvider"] -class AllenteProvider(StreamingProvider): +class AllenteProvider(ManagedProvider): """Allente provider implementation.""" PROVIDER_LABEL: ClassVar[str] = "Allente" @@ -83,6 +83,11 @@ class AllenteProvider(StreamingProvider): # automatically (multi-country path, which lowercases). SUPPORTED_COUNTRIES: ClassVar[List[str]] = list(AllenteDefaults.SUPPORTED_COUNTRIES) + DRM_IN_MANAGERS: ClassVar[bool] = True + # The MPD needs origin/referer/UA (Akamai edge); they come from + # AllenteChannelManager.get_channel_manifest_headers(). + HEADERS_FROM_MANAGERS: ClassVar[bool] = True + @classmethod def supports_country(cls, country: str) -> bool: """Convenience capability check (settings UI, tests). Case-insensitive.""" @@ -102,30 +107,22 @@ class AllenteProvider(StreamingProvider): f"Supported: {', '.join(self.SUPPORTED_COUNTRIES)}" ) - # Normalize to lowercase: every framework manager keys by lowercase - # country ("se"), while the registry constructs single-country - # providers with the UPPERCASE SUPPORTED_COUNTRIES[0] ("SE"). - # Without this, the ProxyConfigManager lookup inside - # _setup_http_manager (country defaults to self.country) and the - # AuthContext.get_credentials() lookup in the auth mixin both miss. + # Normalize to lowercase (see COUNTRY CASE above). self.country = self.country.lower() - # ONE config object, shared with the authenticator, channel - # manager, and DRM builder. No header drift between layers. + # ONE config object, shared with the authenticator, session, + # managers and DRM builder. No header drift between layers. # NOTE: the registry constructs with country only — this dict is - # for programmatic/test construction. Registry-created instances - # rely on framework config systems (CredentialManager, - # ProxyConfigManager, settings manager). + # for programmatic/test construction. self.provider_config = AllenteConfig({ **(config or {}), "country": self.country, }) - # _setup_http_manager (verified signature): proxy resolution order - # is constructor arg -> ProxyConfigManager("allente", "se") -> - # global. user_agent/timeout map onto RequestConfig fields. + # _setup_http_manager: proxy resolution order is constructor arg -> + # ProxyConfigManager("allente", "se") -> global. self.http_manager = self._setup_http_manager( - provider_name="allente", + provider_name=AllenteDefaults.PROVIDER_NAME, proxy_config=proxy_config, user_agent=self.provider_config.user_agent, timeout=self.provider_config.timeout, @@ -136,37 +133,30 @@ class AllenteProvider(StreamingProvider): http_manager=self.http_manager, proxy_config=proxy_config, ) - # No _share_http_manager_with_authenticator call: it returns the - # authenticator's manager when one exists, and ours always does - # (the constructor raises otherwise) — the call would be a no-op - # returning the identical object. + self.session = AllenteSession( + authenticator=self.authenticator, config=self.provider_config + ) - self.channel_manager = AllenteChannelManager(self) + # Provider-owned cache, borrowed by the channel manager. + self._playout_cache: Dict = {} - # Lazy-auth state (populated by _ensure_authenticated) - self.bearer_token: Optional[str] = None - self.entitlement_tag: Optional[str] = None - self._profile: Optional[AllenteProfile] = None - - # Playout cache (channel_id -> (info, timestamp)) - self._playout_cache: Dict[str, Tuple[AllentePlayoutInfo, float]] = {} - - self._last_auth_error: Optional[Exception] = None + self._init_managers() # Opportunistic eager auth — ONLY if credentials are already # stored. Never triggers a login with empty credentials. - if self.authenticator.has_user_credentials(): + if self.session.has_credentials(): try: - self._ensure_authenticated() + self.session.ensure() except Exception as exc: logger.info(f"Allente: pre-login skipped ({exc}); will retry on demand") # ------------------------------------------------------------------ - # Required abstract members + # Required abstract members / metadata # ------------------------------------------------------------------ + @property def provider_name(self) -> str: - return "allente" + return AllenteDefaults.PROVIDER_NAME @property def provider_label(self) -> str: @@ -181,123 +171,55 @@ class AllenteProvider(StreamingProvider): return self.SUPPORTED_AUTH_TYPES # ------------------------------------------------------------------ - # Auth gate — every public method calls this first + # Factory methods # ------------------------------------------------------------------ + + def _build_channels(self): + return AllenteChannelManager( + http_manager=self.http_manager, + auth=self.session, + country=self.country, + config=self.provider_config, + playout_cache=self._playout_cache, + ) + + # _build_vod / epg / recordings / favorites / bookmarks / catchup / + # drm: inherited (None). DRM is folded into the channel manager. + + # ------------------------------------------------------------------ + # Auth surface (settings UI, auth mixin, direct callers) + # ------------------------------------------------------------------ + def _ensure_authenticated(self) -> bool: - """ - Ensure we have a valid Zulu token, entitlement tag, and profile. - Idempotent. Safe to call from any public method. - - The fast path uses the token's own is_expired (the base's property - with its fixed 300s buffer) — the exact same check - BaseAuthenticator.authenticate() uses in step 1, so the two can - never disagree. - """ - tok = self.authenticator.current_token - if ( - self.bearer_token - and self.entitlement_tag - and self._profile - and tok is not None - and not tok.is_expired - ): - return True - - if not self.authenticator.has_user_credentials(): - self._last_auth_error = RuntimeError( - "Allente: no credentials configured. " - "Set username and password in the addon settings." - ) - logger.warning(str(self._last_auth_error)) - return False - - try: - # BaseAuthenticator: cached token -> refresh -> full login. - token = self.authenticator.authenticate() - - # A persisted token from an older schema may be missing the - # entitlement tag. authenticate() would keep returning it - # forever, so discard it once (base invalidate_token() also - # clears persisted storage) and force a fresh login. - if token is not None and not getattr(token, "entitlement_tag", None): - logger.warning( - "Allente: cached token missing entitlementTag — forcing re-login" - ) - self.authenticator.invalidate_token() - token = self.authenticator.authenticate(force_refresh=True) - - if token is None: - self._last_auth_error = RuntimeError( - "Allente: authentication returned no token." - ) - logger.error(str(self._last_auth_error)) - return False - - if getattr(token, "geoblocked", False): - self._last_auth_error = RuntimeError( - f"Allente: account is geoblocked for " - f"{getattr(token, 'content_domain_id', 'unknown')}" - ) - logger.error(str(self._last_auth_error)) - return False - - if not token.entitlement_tag: - self._last_auth_error = RuntimeError( - "Allente: login response is missing entitlementTag — " - "cannot fetch channels." - ) - logger.error(str(self._last_auth_error)) - return False - - self.bearer_token = token.access_token - self.entitlement_tag = token.entitlement_tag - - # Lazy profile fetch (also covers restart with a restored token). - if self._profile is None: - self._profile = self.authenticator.ensure_profile(token) - - if not self._profile: - self._last_auth_error = RuntimeError( - "Allente: no profile available for this account." - ) - logger.error(str(self._last_auth_error)) - return False - - self._last_auth_error = None - logger.info( - f"Allente: authenticated (userId={token.user_id}, " - f"profile={self._profile.id})" - ) - return True - - except AllenteOTPRequiredError as exc: - self._last_auth_error = exc - logger.error(f"Allente: account requires OTP — {exc}") - return False - except Exception as exc: - self._last_auth_error = exc - logger.error(f"Allente: authentication failed — {exc}") - return False + """Compatibility: True when logged in (never raises).""" + return self.session.ensure() def _reset_auth_state(self) -> None: """Clear all auth-derived state (used when credentials change).""" - self.bearer_token = None - self.entitlement_tag = None - self._profile = None - self._last_auth_error = None + self.session.reset() self._playout_cache.clear() # streamIds may be user/token-specific def get_last_auth_error(self) -> Optional[Exception]: """Return the most recent auth error (for direct callers).""" - return self._last_auth_error + return self.session.last_error def get_profile(self) -> Optional[AllenteProfile]: """Return the currently selected profile (populated after auth).""" - return self._profile + return self.session.profile + + @property + def bearer_token(self) -> Optional[str]: + return self.session.bearer_token + + @property + def entitlement_tag(self) -> Optional[str]: + return self.session.entitlement_tag + + @property + def channel_manager(self): + """Compatibility alias for self.channels (the ChannelManager).""" + return self.channels - # ------------------------------------------------------------------ - # Auth-status integration (ProviderAuthMixin hooks) - # ------------------------------------------------------------------ def get_auth_details(self, context) -> Dict: """ Provider-specific auth details for the AuthStatus UI (mixin hook). @@ -306,129 +228,8 @@ class AllenteProvider(StreamingProvider): credentials) and the active profile through the framework's own status channel. Free-form dict per the mixin contract. """ - details: Dict = {} - if self._last_auth_error is not None: - details["last_error"] = str(self._last_auth_error) - details["last_error_type"] = type(self._last_auth_error).__name__ - if self._profile is not None: - details["profile_id"] = self._profile.id - details["profile_kids"] = self._profile.kids - return details + return self.session.details() - # ------------------------------------------------------------------ - # Channels - # ------------------------------------------------------------------ - def get_channels(self, **kwargs) -> List[StreamingChannel]: - if not self._ensure_authenticated(): - return [] - try: - channels = self.channel_manager.get_channels_as_streaming_channels() - self.channels = channels # consumed by to_output_format()/to_json() - return channels - except Exception as exc: - logger.error(f"Allente: get_channels failed — {exc}") - return [] - - # ------------------------------------------------------------------ - # Playout cache — get_manifest and get_drm share one call - # ------------------------------------------------------------------ - def _resolve_playout_cached(self, channel_id: str) -> Optional[AllentePlayoutInfo]: - now = time.time() - cached = self._playout_cache.get(channel_id) - if cached and (now - cached[1]) < AllenteDefaults.PLAYOUT_CACHE_TTL: - return cached[0] - try: - result = self.channel_manager.resolve_playout(channel_id) - except Exception as exc: - logger.error(f"Allente: playout failed for {channel_id} — {exc}") - return None - if result: - self._playout_cache[channel_id] = (result, now) - return result - - # ------------------------------------------------------------------ - # Manifest - # ------------------------------------------------------------------ - def get_manifest(self, content_id: str, **kwargs) -> Optional[str]: - """Resolve the MPD URL for a channel (content_id = channel ID).""" - if not self._ensure_authenticated(): - return None - playout = self._resolve_playout_cached(content_id) - return playout.stream_url if playout else None - - def get_manifest_headers(self, content_id: str, **kwargs) -> Dict[str, str]: - """ - Headers for the MPD fetch. - - Akamai fronts stream-live-01.allente.tv and rejects requests without - a whitelisted origin/referer (returns an Akamai "Access Denied" - HTML page). The CDN also advertises access-control-allow-origin: * - for CORS, but that is a browser-level policy — the actual gate is - enforced at the edge based on these headers. - - We send the same origin/referer/UA the browser sends. - """ - return { - "origin": AllenteDefaults.TV_WEB_ORIGIN, - "referer": AllenteDefaults.TV_WEB_REFERER, - "user-agent": self.provider_config.user_agent, - } - - # ------------------------------------------------------------------ - # DRM (Widevine via Zulu) - # ------------------------------------------------------------------ - def get_drm( - self, - content_id: str, - drm_variant: Optional[str] = None, - **kwargs, - ) -> List[DRMConfig]: - """ - Return DRM configuration for a channel. - - drm_variant is part of the base-class contract (examples: 'auto', - 'software'). v1 accepts and ignores it — the Widevine security - level comes from provider_config.widevine_level. Mapping variants - to levels (software -> L3, hardware -> L1) is a v1.1 candidate and - would also require keying the playout cache by level, since - widevineLevel is a playout request parameter. - - NOTE: the license config embeds the CURRENT bearer token, and ISA - reuses it for the whole playback session. Continuous playback - across token expiry is not supported in v1. - """ - if drm_variant: - logger.debug(f"Allente: get_drm drm_variant={drm_variant!r} — ignored in v1") - - if not self._ensure_authenticated(): - return [] - - playout = self._resolve_playout_cached(content_id) - if not playout: - logger.error(f"Allente: no playout info for channel {content_id}") - return [] - - if playout.drm_type != "Widevine": - logger.warning( - f"Allente: channel {content_id} uses {playout.drm_type}, " - f"not Widevine. Unencrypted/other-DRM playback is not " - f"supported in v1." - ) - return [] - - try: - return [create_allente_widevine_config( - cfg=self.provider_config, - bearer_token=self.bearer_token, - stream_id=playout.stream_id, - )] - except Exception as exc: - logger.error(f"Allente: DRM config build failed for {content_id} — {exc}") - return [] - - # ------------------------------------------------------------------ - # Credentials helper (used by Kodi settings UI) - # ------------------------------------------------------------------ def set_user_credentials( self, username: str, @@ -438,9 +239,9 @@ class AllenteProvider(StreamingProvider): """ Store credentials and log in immediately (exactly one authentication). - The base's invalidate_token() clears BOTH the in-memory token and - the persisted one — required, or a restart would resurrect the - old-credentials token from storage. + invalidate_token() clears BOTH the in-memory token and the persisted + one — required, or a restart would resurrect the old-credentials + token from storage. """ creds = AllenteUserCredentials( username=username, @@ -449,11 +250,11 @@ class AllenteProvider(StreamingProvider): ) self.authenticator.credentials = creds - self.authenticator.invalidate_token() # base: memory + persisted storage + self.authenticator.invalidate_token() # memory + persisted storage self._reset_auth_state() - if not self._ensure_authenticated(): - return False # _last_auth_error is set (OTP, WAF, bad creds, ...) + if not self.session.ensure(): + return False # last_error is set (OTP, WAF, bad creds, ...) self.authenticator.save_credentials(creds) return True \ No newline at end of file diff --git a/lib/streaming_providers/providers/allente/session.py b/lib/streaming_providers/providers/allente/session.py new file mode 100644 index 0000000..cc05a6a --- /dev/null +++ b/lib/streaming_providers/providers/allente/session.py @@ -0,0 +1,238 @@ +# streaming_providers/providers/allente/session.py +""" +AllenteSession -- the auth state of one Allente provider instance. + +It exists because the manager ABCs take an `auth` collaborator that +satisfies AuthProtocol (get_access_token / build_headers / invalidate), +while Allente's login is a stateful, multi-step flow owned by +AllenteAuthenticator (a BaseAuthenticator). The session sits between the +two: + + managers --(AuthProtocol)--> AllenteSession ---> AllenteAuthenticator + +and owns everything the provider used to keep next to the auth gate: +bearer token, entitlement tag, selected profile, last error. + +Error contract +-------------- +`ensure()` returns True/False and never raises (used for the opportunistic +pre-login and by the settings UI). `require()` -- and therefore +get_access_token() / build_headers() -- raises typed errors from +base.errors instead, as the manager contract demands: + + no credentials, wrong password, OTP, unsupported account -> AuthError + geoblocked account -> GeoBlockError + network / WAF / unexpected response -> ServerError + +`last_error` keeps the ORIGINAL exception (AllenteOTPRequiredError, ...) +because the auth-status UI shows its type name. + +Thread-safety: the login runs under an RLock and re-checks readiness after +acquiring it, so concurrent callers produce one login, not N. +""" + +import threading +from typing import Dict, Optional, Tuple, Type + +from ...base.errors import AuthError, GeoBlockError, ProviderError, ServerError +from ...base.utils.logger import logger +from .auth import AllenteAuthError, AllenteOTPRequiredError +from .models import AllenteProfile + + +class AllenteSession: + """Auth state + AuthProtocol adapter for Allente.""" + + def __init__(self, *, authenticator, config): + self.authenticator = authenticator + self.config = config + self.bearer_token: Optional[str] = None + self.entitlement_tag: Optional[str] = None + self.profile: Optional[AllenteProfile] = None + self.last_error: Optional[Exception] = None + self._failure: Optional[Tuple[Type[ProviderError], str]] = None + self._lock = threading.RLock() + + # ------------------------------------------------------------------ + # State + # ------------------------------------------------------------------ + + def has_credentials(self) -> bool: + return self.authenticator.has_user_credentials() + + def reset(self) -> None: + """Clear all auth-derived state (used when credentials change).""" + self.bearer_token = None + self.entitlement_tag = None + self.profile = None + self.last_error = None + self._failure = None + + def _is_ready(self) -> bool: + # The token's own is_expired (the base's fixed 300 s buffer) -- the + # same check BaseAuthenticator.authenticate() uses in step 1, so + # the two can never disagree. + token = self.authenticator.current_token + return bool( + self.bearer_token + and self.entitlement_tag + and self.profile + and token is not None + and not token.is_expired + ) + + def _fail( + self, + exc: Exception, + kind: Type[ProviderError], + *, + message: Optional[str] = None, + warning: bool = False, + ) -> bool: + """Record a failure: `exc` stays visible as last_error, `kind` is + what require() raises.""" + message = message or str(exc) + self.last_error = exc + self._failure = (kind, message) + (logger.warning if warning else logger.error)(message) + return False + + # ------------------------------------------------------------------ + # Auth gate + # ------------------------------------------------------------------ + + def ensure(self) -> bool: + """ + Ensure a valid Zulu token, entitlement tag and profile. + Idempotent; True when ready, False (with last_error set) otherwise. + """ + if self._is_ready(): + return True + with self._lock: + if self._is_ready(): # another thread may have just logged in + return True + return self._authenticate() + + def require(self) -> None: + """ensure() or raise the typed error for the recorded failure.""" + if self.ensure(): + return + kind, message = self._failure or (AuthError, "Allente: not authenticated") + raise kind(message) from self.last_error + + def _authenticate(self) -> bool: + if not self.authenticator.has_user_credentials(): + return self._fail( + RuntimeError( + "Allente: no credentials configured. " + "Set username and password in the addon settings." + ), + AuthError, + warning=True, + ) + + try: + # BaseAuthenticator: cached token -> refresh -> full login. + token = self.authenticator.authenticate() + + # A persisted token from an older schema may be missing the + # entitlement tag. authenticate() would keep returning it + # forever, so discard it once (invalidate_token() also clears + # persisted storage) and force a fresh login. + if token is not None and not getattr(token, "entitlement_tag", None): + logger.warning( + "Allente: cached token missing entitlementTag — forcing re-login" + ) + self.authenticator.invalidate_token() + token = self.authenticator.authenticate(force_refresh=True) + + if token is None: + return self._fail( + RuntimeError("Allente: authentication returned no token."), + AuthError, + ) + + if getattr(token, "geoblocked", False): + return self._fail( + RuntimeError( + f"Allente: account is geoblocked for " + f"{getattr(token, 'content_domain_id', 'unknown')}" + ), + GeoBlockError, + ) + + if not token.entitlement_tag: + return self._fail( + RuntimeError( + "Allente: login response is missing entitlementTag — " + "cannot fetch channels." + ), + ServerError, + ) + + self.bearer_token = token.access_token + self.entitlement_tag = token.entitlement_tag + + # Lazy profile fetch (also covers restart with a restored token). + if self.profile is None: + self.profile = self.authenticator.ensure_profile(token) + + if not self.profile: + return self._fail( + RuntimeError("Allente: no profile available for this account."), + ServerError, + ) + + self.last_error = None + self._failure = None + logger.info( + f"Allente: authenticated (userId={token.user_id}, " + f"profile={self.profile.id})" + ) + return True + + except AllenteOTPRequiredError as exc: + return self._fail( + exc, AuthError, message=f"Allente: account requires OTP — {exc}" + ) + except AllenteAuthError as exc: # wrong credentials, unsupported account + return self._fail( + exc, AuthError, message=f"Allente: authentication failed — {exc}" + ) + except Exception as exc: # network, 5xx, WAF, unexpected SSO response + return self._fail( + exc, ServerError, message=f"Allente: authentication failed — {exc}" + ) + + # ------------------------------------------------------------------ + # AuthProtocol (what the managers see) + # ------------------------------------------------------------------ + + def get_access_token(self) -> str: + self.require() + return self.bearer_token # type: ignore[return-value] + + def build_headers(self) -> Dict[str, str]: + """Zulu API headers incl. the bearer token.""" + self.require() + return self.config.zulu_headers(self.bearer_token) + + def invalidate(self) -> None: + """Drop the token everywhere (memory + persisted) and the state.""" + self.authenticator.invalidate_token() + self.reset() + + # ------------------------------------------------------------------ + # Status UI + # ------------------------------------------------------------------ + + def details(self) -> Dict: + """Provider-specific details for the auth-status UI (free-form).""" + details: Dict = {} + if self.last_error is not None: + details["last_error"] = str(self.last_error) + details["last_error_type"] = type(self.last_error).__name__ + if self.profile is not None: + details["profile_id"] = self.profile.id + details["profile_kids"] = self.profile.kids + return details \ No newline at end of file diff --git a/lib/streaming_providers/providers/simpli/recordings_manager.py b/lib/streaming_providers/providers/simpli/recordings_manager.py index 1c009bf..ebf2634 100644 --- a/lib/streaming_providers/providers/simpli/recordings_manager.py +++ b/lib/streaming_providers/providers/simpli/recordings_manager.py @@ -229,7 +229,8 @@ class SimpliTVRecordingsManager(RecordingsManager): # Internal # ------------------------------------------------------------------ - def _rec_to_recording(self, rec: dict) -> SimpliTVRecording: + @staticmethod + def _rec_to_recording(rec: dict) -> SimpliTVRecording: """ Map one GetRecordings entry to SimpliTVRecording.