migrate allente to new structure

This commit is contained in:
Nirvana
2026-10-09 12:38:39 +02:00
parent 10fb5fa8e9
commit 3147ae85ad
6 changed files with 556 additions and 379 deletions
@@ -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
@@ -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())
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,
)]
@@ -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,
@@ -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
@@ -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
@@ -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.