mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-12 10:52:23 +02:00
migrate allente to new structure
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user