Files
script.service.ultimate/lib/streaming_providers/providers/movetv/auth.py
T
2026-04-08 15:39:56 +02:00

424 lines
17 KiB
Python

# streaming_providers/providers/movetv/auth.py
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional, cast
from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel
from ...base.auth.credentials import UserPasswordCredentials
from ...base.utils.logger import logger
from .constants import MoveTVConfig
@dataclass
class MoveTVAuthToken(BaseAuthToken):
"""
Concrete implementation of MoveTVAuthToken.
Note: device_id is used here to store the persistent Move.tv UID.
"""
customer_id: int = 0
customer_profile_id: int = 0
dedicated_server: str = ""
uid: str = ""
device_id: str = ""
def to_dict(self) -> Dict[str, Any]:
"""Concrete implementation of abstract method."""
return {
# Stored under both keys: "access_token" for the base class and
# "auth_token" to match the raw API shape, ensuring round-trip
# whether reading from the live response or persisted session data.
"access_token": self.access_token,
"auth_token": self.access_token,
"token_type": self.token_type,
"expires_in": self.expires_in,
"issued_at": self.issued_at,
"refresh_token": self.refresh_token,
"refresh_expires_in": self.refresh_expires_in,
"auth_level": self.auth_level.value,
"credential_type": self.credential_type,
"customer_id": self.customer_id,
"customer_profile_id": self.customer_profile_id,
"dedicated_server": self.dedicated_server,
"uid": self.uid,
"device_id": self.device_id,
}
class MoveTVAuthenticator(BaseAuthenticator):
"""
Concrete implementation of MoveTVAuthenticator.
"""
# Parameters accepted by BaseAuthenticator.__init__
_BASE_INIT_PARAMS = {
"provider_name", "settings_manager", "credentials",
"country", "config_dir", "enable_kodi_integration",
}
def __init__(self, *args, **kwargs):
# provider_name is a @property on the base class, so it must NOT be
# passed as a constructor argument — pass the literal string directly.
if args:
# Positional: first arg is provider_name; replace with our value.
args = ("movetv",) + args[1:]
else:
kwargs["provider_name"] = "movetv"
# Consume kwargs unknown to BaseAuthenticator (e.g. proxy_config)
# before forwarding, storing them as instance attributes.
extra = {k: v for k, v in kwargs.items() if k not in self._BASE_INIT_PARAMS}
for k in extra:
kwargs.pop(k)
super().__init__(*args, **kwargs)
# Attach extra kwargs as attributes so subclass code can use them.
for k, v in extra.items():
setattr(self, k, v)
self.http_manager: Any = getattr(self, "http_manager", None)
# ------------------------------------------------------------------
# Abstract methods required by BaseAuthenticator
# ------------------------------------------------------------------
@property
def auth_endpoint(self) -> str:
"""Authentication endpoint URL."""
return MoveTVConfig.login_url()
def _get_auth_headers(self) -> Dict[str, str]:
"""Headers for the authentication request."""
return MoveTVConfig.get_base_headers()
def _build_auth_payload(self) -> Dict[str, Any]:
"""Build the login payload from current credentials."""
if not self.credentials or not isinstance(self.credentials, UserPasswordCredentials):
raise ValueError("move.tv requires username/password credentials")
uid = self.get_device_id()
return {
"username": self.credentials.username,
"password": self.credentials.password,
"partnerId": MoveTVConfig.PARTNER_ID,
"deviceName": MoveTVConfig.DEVICE_NAME,
"deviceModelId": MoveTVConfig.DEVICE_MODEL_ID,
"uid": uid,
"appVersion": MoveTVConfig.APP_VERSION,
}
def _create_token_from_response(self, response_data: Dict[str, Any]) -> MoveTVAuthToken:
"""
Create a MoveTVAuthToken from stored token data or a raw API response.
Handles both shapes so that _load_session() works correctly.
Stored sessions may be missing keys like 'access_token' (stored as
'auth_token' only) or 'issued_at' (older sessions). Both are handled
gracefully so that _load_session() never raises and discards the token.
"""
uid = response_data.get("device_id") or response_data.get("uid", "")
profile = response_data.get("profile", {})
auth_level_raw = response_data.get("auth_level", TokenAuthLevel.USER_AUTHENTICATED.value)
if isinstance(auth_level_raw, str):
try:
auth_level = TokenAuthLevel(auth_level_raw)
except ValueError:
auth_level = TokenAuthLevel.USER_AUTHENTICATED
else:
auth_level = auth_level_raw
# 'access_token' and 'auth_token' are the same value; accept either.
access_token = (
response_data.get("access_token")
or response_data.get("auth_token", "")
)
# 'issued_at' is absent from sessions saved before this field was
# added. Use a sentinel far in the future so is_expired stays False
# and we always reach validate_token() rather than forcing a login.
issued_at = response_data.get("issued_at")
if issued_at is None:
# No timestamp stored → treat token as freshly issued so the
# base-class expiry check never fires. validate_token() will
# confirm liveness with the server instead.
issued_at = time.time()
logger.debug(
"move.tv: Session has no issued_at; defaulting to now so "
"validate_token() decides liveness"
)
# expires_in: use a very large value when absent so is_expired==False.
# Again, the server-side validate call is the real freshness check.
expires_in_raw = response_data.get("expires_in") or response_data.get("t")
expires_in = int(expires_in_raw) if expires_in_raw is not None else 86400 * 365
token = MoveTVAuthToken(
access_token=access_token,
token_type=response_data.get("token_type", "token"),
expires_in=expires_in,
issued_at=float(issued_at),
refresh_token=response_data.get("refresh_token") or None,
refresh_expires_in=int(response_data.get("refresh_expires_in", 0)),
auth_level=auth_level,
customer_id=response_data.get("customer_id", 0),
customer_profile_id=(
response_data.get("customer_profile_id") or profile.get("id", 0)
),
dedicated_server=response_data.get("dedicated_server", ""),
uid=uid,
device_id=uid,
)
logger.debug(
f"move.tv: Built token — access_token={'***' if token.access_token else '<empty>'}, "
f"customer_id={token.customer_id}, uid={token.uid}, "
f"is_expired={token.is_expired}"
)
return token
def get_fallback_credentials(self):
"""No anonymous / fallback credentials for Move.tv — login is required."""
return None
def _perform_authentication(self) -> MoveTVAuthToken:
"""
Execute the actual HTTP login request and return a token.
Called by the base class authenticate() after credential validation.
"""
payload = self._build_auth_payload()
uid = payload["uid"]
logger.debug(f"move.tv: Authenticating with slot UID: {uid}")
response = self.http_manager.post(
self.auth_endpoint,
json=payload,
headers=self._get_auth_headers(),
operation="auth",
)
response.raise_for_status()
data = response.json()
if not data.get("success"):
message = data.get("message", "Unknown login error")
raise RuntimeError(f"Move.tv auth failed: {message}")
return self._create_token_from_response({**data, "uid": uid, "device_id": uid})
def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel:
"""Concrete implementation of abstract method."""
return TokenAuthLevel.USER_AUTHENTICATED
# ------------------------------------------------------------------
# Token validation
# ------------------------------------------------------------------
def validate_token(self, token: MoveTVAuthToken) -> bool:
"""
Call the Move.tv token-validate endpoint to confirm the stored token
is still accepted by the server.
Returns True if the server reports "Token active.", False otherwise.
The token fields (dedicated_server etc.) are NOT updated here because
the validate response may contain fresh server URLs — callers that need
those values should read them from get_session_info() after a successful
validate, or trigger a full re-login on failure.
"""
if not token.access_token:
logger.debug("move.tv: validate_token called with empty access_token, skipping")
return False
headers = {
**MoveTVConfig.get_base_headers(),
"x-auth-token": token.access_token,
"x-refresh-token": token.refresh_token or "",
}
payload = {
"customerId": token.customer_id,
"appVersion": MoveTVConfig.APP_VERSION,
}
logger.debug(
f"move.tv: Validating token for customer_id={token.customer_id}"
)
try:
response = self.http_manager.post(
MoveTVConfig.validate_url(),
json=payload,
headers=headers,
operation="token_validate",
)
response.raise_for_status()
data = response.json()
except Exception as exc:
logger.debug(f"move.tv: Token validation request failed: {exc}")
return False
if data.get("message") == "Token active.":
logger.debug("move.tv: Token is active")
return True
# Token is not active — log the full response so we can diagnose
# what the API returned (new tokens, error codes, etc.).
logger.debug(f"move.tv: Token not active, server response: {data}")
return False
# ------------------------------------------------------------------
# Token refresh via /api/v2/token/status
# ------------------------------------------------------------------
def refresh_token(self, token: "MoveTVAuthToken") -> Optional["MoveTVAuthToken"]:
"""
Exchange the current refresh_token for a fresh auth_token + refresh_token
using the ``/api/v2/token/status`` endpoint.
The API expects the *old* refresh_token in the ``x-refresh-token`` header
and ``customerProfileId`` / ``appVersion`` in the JSON body. On success
it returns new ``auth_token`` and ``refresh_token`` values together with
updated server URLs.
Returns a new :class:`MoveTVAuthToken` (already persisted) on success,
or ``None`` if the refresh fails (caller should fall back to full login).
"""
if not token.refresh_token:
logger.debug("move.tv: refresh_token called but no refresh_token stored, skipping")
return None
headers = {
**MoveTVConfig.get_base_headers(),
"x-refresh-token": token.refresh_token,
}
payload = {
"customerProfileId": token.customer_profile_id,
"appVersion": MoveTVConfig.APP_VERSION,
}
logger.debug(
f"move.tv: Refreshing token for customer_profile_id={token.customer_profile_id}"
)
try:
response = self.http_manager.post(
MoveTVConfig.token_status_url(),
json=payload,
headers=headers,
operation="token_refresh",
)
response.raise_for_status()
data = response.json()
except Exception as exc:
logger.debug(f"move.tv: Token refresh request failed: {exc}")
return None
if not data.get("success") or data.get("message") != "Token active.":
logger.debug(f"move.tv: Token refresh rejected by server: {data}")
return None
# Merge refreshed fields into a new token, preserving any fields not
# returned by the status endpoint (e.g. credential_type).
existing = token.to_dict()
merged = {
**existing,
# The status response uses "auth_token"; map to both keys.
"access_token": data.get("auth_token", existing.get("access_token")),
"auth_token": data.get("auth_token", existing.get("auth_token")),
"refresh_token": data.get("refresh_token", existing.get("refresh_token")),
# "t" is the new expires_in value from the status response.
"expires_in": data.get("t", existing.get("expires_in")),
# Reset the issue timestamp so is_expired is calculated against
# *now*, not the original login time. Without this the merged
# token inherits the old issued_at and is immediately stale.
"issued_at": time.time(),
"dedicated_server": data.get("dedicated_server", existing.get("dedicated_server")),
"customer_id": int(data.get("customer_id") or existing.get("customer_id", 0)),
# IMPORTANT: The Move.tv API response field "device_id" is a
# server-assigned device identifier — it is NOT the same as the
# base-class device_id, which is our persistent local UID slot
# (analogous to how the API calls the auth token "auth_token" but
# the base class calls it "access_token"). We must preserve the
# existing uid/device_id so the base class keeps its value intact.
}
new_token = self._create_token_from_response(merged)
self._current_token = new_token
self._save_session()
logger.info(
f"move.tv: Token refreshed successfully — "
f"customer_id={new_token.customer_id}, uid={new_token.uid}"
)
return new_token
def authenticate(self, force_refresh: bool = False) -> MoveTVAuthToken:
"""
Move.tv sessions do not carry an explicit server-side expiry we can
rely on. When we have a stored token we validate it with the API
instead of checking the local is_expired flag.
Flow:
1. No stored token → full login
2. Stored token, valid → return as-is
3. Stored token, invalid → full login (future: try refresh first)
4. force_refresh=True → full login unconditionally
"""
logger.debug(
f"move.tv: authenticate(force_refresh={force_refresh}) — "
f"has_token={self._current_token is not None}"
)
if force_refresh or not self._current_token:
logger.debug("move.tv: No stored token or force_refresh — proceeding to full login")
return self._full_login()
token = cast(MoveTVAuthToken, self._current_token)
if self.validate_token(token):
return token
logger.info("move.tv: Stored token failed validation, attempting token refresh")
refreshed = self.refresh_token(token)
if refreshed:
return refreshed
logger.info("move.tv: Token refresh failed, performing full login")
return self._full_login()
def _full_login(self) -> MoveTVAuthToken:
"""Ensure credentials, run the login request, persist the session."""
if not self._ensure_credentials():
raise Exception("No valid credentials available for move.tv")
token = self._perform_authentication()
self._current_token = token
self._save_session()
logger.info("move.tv: Authentication successful")
return token
# ------------------------------------------------------------------
# Public helpers (unchanged interface)
# ------------------------------------------------------------------
def get_auth_token(self, force_refresh: bool = False) -> str:
return self.authenticate(force_refresh).access_token
def get_current_token(self) -> MoveTVAuthToken:
return cast(MoveTVAuthToken, self._current_token)
def get_session_info(self) -> Optional[Dict[str, Any]]:
"""
Returns info required for playback.
Casts the token to MoveTVAuthToken to resolve attribute errors.
"""
if not self._current_token:
return None
t = cast(MoveTVAuthToken, self._current_token)
return {
"customer_id": t.customer_id,
"customer_profile_id": t.customer_profile_id,
"uid": t.device_id,
"dedicated_server": t.dedicated_server,
"auth_token": t.access_token,
}