mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-21 07:12:38 +02:00
207 lines
7.8 KiB
Python
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 |