Files
script.service.ultimate/lib/streaming_providers/providers/movetv/session.py
T
2026-10-14 09:59:46 +02:00

177 lines
6.9 KiB
Python

# streaming_providers/providers/movetv/session.py
"""
AUTH VARIANT B -- AuthProtocol adapter in front of MoveTVAuthenticator
(README §9). Managers talk to this object; the raw authenticator stays on the
provider (`self.authenticator`).
managers --(AuthProtocol)--> MoveTvSession --> MoveTVAuthenticator
Differences to the v1 usage (each is a recorded behaviour change):
* the token is validated against the server ONCE per process (first login of
the stored session) and afterwards only when the server answers 401
(`recover()`), instead of a validate POST in every authenticate() call;
* rejected credentials / missing credentials are PERMANENT (typed AuthError):
no retry until reset() -- no login loop, no account lock;
* any other typed ProviderError keeps its type and backs off (transient);
untyped errors become ServerError;
* `ensure()` is for the settings UI only; managers use require()/info().
Error contract: ensure() -> bool, never raises. require()/info()/recover()
raise typed errors. last_error keeps the ORIGINAL exception.
"""
import threading
import time
from typing import Any, Callable, Dict, List, Optional, Tuple, Type
from ...base.errors import AuthError, ProviderError, ServerError
from ...base.utils.logger import logger
BACKOFF_BASE_SECONDS = 30
BACKOFF_MAX_SECONDS = 900
class MoveTvSession:
PERMANENT_ERRORS: Tuple[Type[Exception], ...] = (AuthError,)
def __init__(self, *, authenticator: Any, config: Any,
clock: Callable[[], float] = time.monotonic):
self.authenticator = authenticator
self.config = config
self._clock = clock
self.last_error: Optional[Exception] = None
self._failure: Optional[Tuple[Type[ProviderError], str]] = None
self._logged_in = False
self._permanent = False
self._failures = 0
self._retry_at = 0.0
self._lock = threading.RLock()
self._on_invalidate: List[Callable[[], None]] = []
# ---- state ---------------------------------------------------------
def on_invalidate(self, callback: Callable[[], None]) -> None:
"""Register a callback run by invalidate() (the provider clears its
account-specific caches there)."""
self._on_invalidate.append(callback)
def reset(self) -> None:
"""Credentials changed: forget everything, including the backoff."""
self._logged_in = False
self.last_error = None
self._failure = None
self._permanent = False
self._failures = 0
self._retry_at = 0.0
def _token(self) -> Any:
return self.authenticator.get_current_token()
def _ready(self) -> bool:
token = self._token()
return bool(
self._logged_in and token is not None
and getattr(token, "access_token", None) and not token.is_expired
)
# ---- failure bookkeeping ---------------------------------------------
def _typed(self, exc: Exception) -> Tuple[Type[ProviderError], bool]:
if isinstance(exc, ProviderError):
return type(exc), isinstance(exc, self.PERMANENT_ERRORS)
return ServerError, False
def _record_failure(self, exc: Exception) -> None:
kind, permanent = self._typed(exc)
self.last_error = exc
self._failure = (kind, f"authentication failed -- {exc}")
self._logged_in = False
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(f"move.tv: {self._failure[1]}")
def _record_success(self) -> None:
self._logged_in = True
self.last_error, self._failure = None, None
self._permanent = False
self._failures, self._retry_at = 0, 0.0
def _raise_failure(self) -> None:
kind, message = self._failure or (AuthError, "not authenticated")
try:
error: ProviderError = kind(message)
except TypeError: # exotic constructor shape
error = ServerError(message)
raise error from self.last_error
# ---- gate ----------------------------------------------------------
def ensure(self) -> bool:
if self._ready():
return True
with self._lock:
if self._ready():
return True
if self._permanent:
return False # until reset()
if self._clock() < self._retry_at:
return False # still backing off
return self._login()
def require(self) -> None:
if self.ensure():
return
self._raise_failure()
def _login(self) -> bool:
try:
token = self.authenticator.authenticate()
if token is None:
raise AuthError("move.tv: authentication returned no token")
except Exception as exc:
self._record_failure(exc)
return False
self._record_success()
return True
def recover(self) -> None:
"""The server rejected the token (401): refresh it, else log in again.
Raises typed errors; a failed full login is permanent (AuthError)."""
with self._lock:
try:
token = self._token()
renewed = self.authenticator.refresh_token(token) if token else None
if renewed is None:
renewed = self.authenticator.authenticate(force_refresh=True)
if renewed is None:
raise AuthError("move.tv: re-authentication returned no token")
except Exception as exc:
self._record_failure(exc)
self._raise_failure()
self._record_success()
def info(self, *, need_profile: bool = True) -> Dict[str, Any]:
"""Session data the API payloads need (customer ids, token)."""
self.require()
info = self.authenticator.get_session_info()
if (not info or not info.get("customer_id") or not info.get("auth_token")
or (need_profile and not info.get("customer_profile_id"))):
raise AuthError("move.tv: incomplete session data")
return info
# ---- AuthProtocol --------------------------------------------------
def get_access_token(self) -> str:
self.require()
return self._token().access_token
def build_headers(self) -> Dict[str, str]:
return self.config.get_api_headers(self.get_access_token())
def invalidate(self) -> None:
self.authenticator.invalidate_token() # memory AND persisted storage
self.reset()
for callback in list(self._on_invalidate):
try:
callback()
except Exception as exc: # a cache-clearing bug must not block logout
logger.warning(f"move.tv: on_invalidate callback failed: {exc}")