Files
script.service.ultimate/lib/streaming_providers/providers/joyn/session.py
T
2026-10-11 08:13:47 +02:00

207 lines
7.8 KiB
Python

# streaming_providers/providers/joyn/session.py
# -*- coding: utf-8 -*-
"""
Auth Variant B — a session adapter in front of the stateful JoynAuthenticator.
managers --(AuthProtocol)--> JoynSession --> JoynAuthenticator
The managers consume JoynSession through AuthProtocol (get_access_token /
build_headers / invalidate). The raw authenticator stays on the provider for one
reason: ProviderAuthMixin reaches for `self.authenticator.get_bearer_token()`
directly (shared with v1 providers). The managers never touch it.
Error contract
--------------
ensure() -> bool, never raises (settings UI, opportunistic pre-login)
require() -> raises typed errors
last_error keeps the ORIGINAL exception for the auth-status UI
* The TYPE of a ProviderError raised by the authenticator is preserved
(JoynMfaRequiredException -> stays an AuthError, a WAF block stays whatever
the authenticator made it). Only unexpected, untyped exceptions become
ServerError. README §9: "the session keeps its type".
* PERMANENT (MFA, rejected credentials): no retry until reset(). The 7pass
login is rate-limited and locks accounts on repeated failures.
* TRANSIENT (network, WAF, 5xx, rate limit): exponential backoff
BACKOFF_BASE_SECONDS * 2^(n-1), capped at BACKOFF_MAX_SECONDS.
Concurrency: one login for concurrent callers (RLock + re-check).
Invalidation: invalidate() clears the authenticator (memory + persisted), resets
the backoff state and then runs the registered `on_invalidate` callbacks — the
provider uses that to clear its playout / entitlement / VOD caches, which are
account-specific.
"""
import threading
import time
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple, Type
from ...base.errors import AuthError, ProviderError, ServerError
from ...base.utils.logger import logger
from .models import JoynAuthError, JoynMfaRequiredException
if TYPE_CHECKING: # the session depends on the authenticator's *shape*, not its module
from .auth import JoynAuthenticator
BACKOFF_BASE_SECONDS = 30
BACKOFF_MAX_SECONDS = 900
class JoynSession:
# Failures that retrying with the same input cannot fix.
# JoynMfaRequiredException — the user must disable MFA.
# JoynAuthError — 7pass rejected the credentials / no auth code.
PERMANENT_ERRORS: Tuple[Type[Exception], ...] = (
JoynMfaRequiredException,
JoynAuthError,
)
def __init__(
self,
*,
authenticator: "JoynAuthenticator",
config,
clock: Callable[[], float] = time.monotonic,
on_invalidate: Optional[Callable[[], None]] = None,
):
self.authenticator = authenticator
self.config = config
self._clock = clock
self.last_error: Optional[Exception] = None
self._failures = 0
self._retry_at = 0.0
self._permanent = False
self._failure_kind: Optional[Tuple[Type[Exception], str]] = None
self._on_invalidate: List[Callable[[], None]] = []
if on_invalidate is not None:
self._on_invalidate.append(on_invalidate)
self._lock = threading.RLock()
# ------------------------------------------------------------------
# AuthProtocol
# ------------------------------------------------------------------
def get_access_token(self) -> str:
self.require()
token = self.authenticator.current_token
if token is None or token.is_expired:
# Expired between require() and here (clock edge): one more gate pass.
self.require()
token = self.authenticator.current_token
if token is None:
raise AuthError("not authenticated")
return token.access_token
def build_headers(self) -> Dict[str, str]:
"""ABC default header set (Joyn API headers + bearer). Managers needing
the CDN / entitlement / DRM sets call JoynConfig directly."""
return self.config.api_headers(self.get_access_token())
def invalidate(self) -> None:
"""Clear memory and persisted storage, reset backoff, drop account caches."""
self.authenticator.invalidate_token() # memory + persisted
self.reset()
for callback in list(self._on_invalidate):
try:
callback()
except Exception as exc: # a broken cache hook must not block a logout
logger.warning(f"Joyn: invalidate callback failed: {exc!r}")
# ------------------------------------------------------------------
# Gate
# ------------------------------------------------------------------
def ensure(self) -> bool:
"""Never raises. True if a usable token exists afterwards."""
if self._ready():
return True
with self._lock:
if self._ready():
return True
if self._permanent:
return False
if self._clock() < self._retry_at:
return False
return self._login()
def require(self) -> None:
"""Raises a typed error if no usable token can be produced."""
if self.ensure():
return
kind, message = self._failure_kind or (AuthError, "not authenticated")
try:
error = kind(message)
except TypeError: # exotic constructor on a provider-specific type
error = ServerError(message)
raise error from self.last_error
def reset(self) -> None:
"""Credentials changed: forget everything, including backoff."""
with self._lock:
self.last_error = None
self._failure_kind = None
self._permanent = False
self._failures = 0
self._retry_at = 0.0
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _ready(self) -> bool:
token = self.authenticator.current_token
return token is not None and not token.is_expired
def _login(self) -> bool:
# With no stored credentials the authenticator falls back to the anonymous
# flow: Joyn works without a login (v1 behaviour, preserved).
try:
self._fetch_token()
except Exception as exc:
permanent = isinstance(exc, self.PERMANENT_ERRORS)
kind: Type[Exception] = type(exc) if isinstance(exc, ProviderError) else ServerError
return self._fail(exc, kind, permanent=permanent)
if not self._ready():
return self._fail(
RuntimeError("authenticator returned no usable token"),
AuthError,
permanent=False,
)
self.last_error = None
self._failure_kind = None
self._permanent = False
self._failures = 0
self._retry_at = 0.0
return True
def _fetch_token(self) -> None:
"""
Let the authenticator produce a token.
First WITHOUT force_refresh: a valid persisted token (or a refresh-token
refresh) must not trigger a full 7pass login at every process start. Only
if that still leaves no usable token do we force a fresh login.
"""
self.authenticator.get_bearer_token()
if not self._ready():
self.authenticator.get_bearer_token(force_refresh=True)
def _fail(self, exc: Exception, kind: Type[Exception], *, permanent: bool) -> bool:
self.last_error = exc
self._failure_kind = (kind, f"authentication failed — {exc}")
self._permanent = permanent
if not permanent:
self._failures += 1
delay = min(
BACKOFF_BASE_SECONDS * 2 ** (self._failures - 1),
BACKOFF_MAX_SECONDS,
)
self._retry_at = self._clock() + delay
logger.error(self._failure_kind[1])
return False