Files
script.service.ultimate/lib/streaming_providers/providers/simpli/auth.py
T

488 lines
18 KiB
Python

# streaming_providers/providers/simpli/auth.py
"""
simpliTV authentication.
Two deliberate deviations from the AuthProtocol's *implied* shape:
1. The simpliTV token is passed as a *query parameter* (GET) or *body
field* (POST), never as an Authorization header. build_headers()
therefore returns base headers only; callers attach the token via
with_token() / auth_body(). One endpoint (GetRecordings) names the
query parameter `tokenValue` instead of `token`.
2. A second credential, the *device key*, is required by
AcquireContent. It is discovered from the server (GetDevices) and
registered once if the account has none. Sessions are not
persisted: the token is cheap to re-acquire.
Device key lifecycle
--------------------
On first use:
1. GetDevices. If the account already has devices, reuse the first
one's key. This is the common case on a re-install.
2. If GetDevices is empty, generate a fresh key and RegisterDevice.
3. GetDevices again and use the first entry. If the server does not
hand a device back, raise -- do not proceed with an unverified
key. The read-back, not the RegisterDevice response, is the
authoritative check (the RegisterDevice response shape is not
verified).
The key is cached in-process only. A process restart therefore hits
GetDevices again, which returns the already-registered device.
Error handling: network / JSON failures in the device calls are wrapped
in ServerError by transport_errors(); typed provider errors (AuthError
etc.) pass through unchanged.
Credential loading
------------------
Credentials are looked up in two places, in order:
1. An injected settings_manager (if the host passes one) via
get_provider_credentials(provider, country).
2. CredentialManager directly, which reads credentials.json.
The host's provider registry instantiates providers without a
settings_manager, so path (2) is the one that fires in normal
operation. CredentialManager.load_credentials handles both the
country-nested format ({"simpli": {"at": {...}}}) and the flat format
({"simpli": {...}}), so an existing flat credentials.json works
unchanged.
Authenticate request shape
--------------------------
The browser sends the Authenticate body as JSON text but with
Content-Type: text/plain (NOT application/json). The server returns a
different, token-less response when it sees application/json. The body
is therefore sent as a raw string (data=...) with Content-Type
text/plain set explicitly for this call, matching the browser capture.
Everything else on the API uses Content-Type: application/json, so
this override applies only to Authenticate.
"""
import json
import secrets
import threading
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
from ...base.auth.base_auth import BaseAuthToken
from ...base.errors import AuthError, CredentialsError
from ...base.utils.logger import logger
from .constants import SimpliTVConfig, SimpliTVDefaults
from .helpers import parse_iso, transport_errors
from .models import SimpliTVAuthToken
def _generate_device_key() -> str:
"""
Return a 10-digit numeric device key.
The browser log shows the format the server accepts:
"1421859737". Earlier versions of this provider used a 32-char
lowercase-alnum string (ported from the addon); that shape is not
what the API advertises.
"""
return f"{secrets.randbelow(10**10):010d}"
def _mask(key: Optional[str]) -> str:
"""Short tag for logs; never the full key."""
if not key or len(key) < 6:
return "***"
return f"{key[:2]}...{key[-2:]}"
def _parse_expiry(iso: Optional[str]) -> Optional[int]:
"""
Parse `tokenExpirationTime` to seconds from now. Returns None on
missing / malformed input or a time already in the past.
"""
dt = parse_iso(iso)
if dt is None:
return None
delta = (dt - datetime.now(timezone.utc)).total_seconds()
if delta <= 0:
return None
return int(delta)
def _extract_device_key(entry: Any) -> str:
"""
Return the device key from one GetDevices entry.
The response shape is unverified (the browser capture never calls
GetDevices). "key" is the addon's field name; "deviceKey" is the
field the RegisterDevice payload uses. Either is accepted; if
neither is present (or the entry is not an object), raise rather
than silently return empty.
"""
if not isinstance(entry, dict):
raise AuthError(
f"simpliTV: GetDevices entry is not an object: "
f"{type(entry).__name__}"
)
for field in ("key", "deviceKey"):
value = entry.get(field)
if value:
return str(value)
raise AuthError(
f"simpliTV: GetDevices entry has no device key field "
f"(keys: {sorted(entry.keys())!r})"
)
class SimpliTVAuth:
"""
Authenticator for simpliTV.
Not a subclass of any base class -- matches AuthProtocol by shape
for the three shared methods, plus simpliTV-specific helpers
(with_token, auth_body) and the device-key accessor.
Thread-safe: the backend serves requests from several threads, and
login / device registration must happen once, not once per thread.
"""
def __init__(
self,
*,
http_manager,
country: str,
settings_manager=None,
credentials=None,
config: Optional[SimpliTVConfig] = None,
**provider_opts,
):
self.http_manager = http_manager
self.country = country
self.settings_manager = settings_manager
self._credentials = credentials
self.config = config or SimpliTVConfig()
self._cached_token: Optional[BaseAuthToken] = None
self._device_key: Optional[str] = None
self._lock = threading.RLock()
# ------------------------------------------------------------------
# The three shared methods
# ------------------------------------------------------------------
def get_access_token(self, force_refresh: bool = False) -> str:
with self._lock:
if (
not force_refresh
and self._cached_token
and not self._cached_token.is_expired
):
return self._cached_token.access_token
self._cached_token = self._perform_authentication()
return self._cached_token.access_token
def build_headers(
self, token: Optional[str] = None, **opts
) -> Dict[str, str]:
"""
Return *base* headers for the simpliTV API.
The token is deliberately NOT placed here -- see with_token()
and auth_body(). The `token` argument is accepted for protocol
compatibility and ignored.
"""
return self.config.get_api_headers()
def invalidate(self) -> None:
"""
Drop the cached token; the next call logs in again.
The device key is kept: it identifies the install, not the
session.
"""
with self._lock:
self._cached_token = None
# ------------------------------------------------------------------
# simpliTV-specific helpers
# ------------------------------------------------------------------
def with_token(
self,
url: str,
params: Optional[Dict[str, Any]] = None,
*,
param: str = SimpliTVDefaults.TOKEN_PARAM,
) -> str:
"""
Append the token (as `param`) and any extra params to `url`.
NOTE: this parses and re-encodes the existing query string, so
the pre-encoded `$headers` blob is re-encoded. The key/value
pairs are identical, but the bytes differ from the browser
capture. If the server ever proves sensitive to that, build the
query string by hand instead.
"""
token = self.get_access_token()
parsed = urlparse(url)
query = dict(parse_qsl(parsed.query))
query[param] = token
if params:
query.update(params)
return urlunparse(parsed._replace(query=urlencode(query)))
def auth_body(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Return `payload` with the token merged in as a body field."""
merged = dict(payload)
merged["token"] = self.get_access_token()
return merged
def get_device_key(self) -> str:
"""
Return the account's device key.
Order of preference:
1. In-process cache.
2. GetDevices -- reuse the first registered device.
3. RegisterDevice with a fresh key, then GetDevices again.
"""
with self._lock:
if self._device_key:
return self._device_key
devices = self._list_devices()
if devices:
self._device_key = _extract_device_key(devices[0])
logger.debug(
f"simpliTV[{self.country}]: reusing device "
f"{_mask(self._device_key)}"
)
return self._device_key
self._register_device(_generate_device_key())
devices = self._list_devices()
if not devices:
raise AuthError(
"simpliTV: device registration did not result in a "
"registered device (GetDevices is still empty)"
)
self._device_key = _extract_device_key(devices[0])
logger.debug(
f"simpliTV[{self.country}]: registered device "
f"{_mask(self._device_key)}"
)
return self._device_key
# ------------------------------------------------------------------
# Provider-specific implementation
# ------------------------------------------------------------------
def _perform_authentication(self) -> BaseAuthToken:
"""
Log in and return a BaseAuthToken.
IMPORTANT: the request body is sent as a raw JSON *string* with
Content-Type: text/plain, matching the browser capture. When the
same body is sent as application/json, the server responds 200
with a short token-less body. Do not switch this to json=...
without re-verifying against the live endpoint.
"""
creds = self._resolve_credentials()
logger.debug(f"simpliTV[{self.country}]: logging in")
# The browser sends lowercase `login`/`password` and does NOT
# send LongExpiration.
payload = {
"platformCodename": self.config.platform_codename,
"login": creds["username"],
"password": creds["password"],
}
headers = self.build_headers()
# Override Content-Type for this call only. The API accepts the
# JSON body as text/plain; application/json yields a different,
# token-less response.
headers["Content-Type"] = "text/plain"
body = json.dumps(payload)
logger.debug(
f"simpliTV[{self.country}]: Authenticate request "
f"(Content-Type=text/plain, {len(body)} bytes)"
)
resp = self.http_manager.post(
self.config.authenticate_url(),
data=body,
headers=headers,
)
data = resp.json()
token_value = data.get("token")
if not token_value:
# Log the response shape on failure so the cause is visible
# without another round trip. Never log the token itself.
keys = list(data.keys()) if isinstance(data, dict) else type(data).__name__
logger.error(
f"simpliTV[{self.country}]: no token in authenticate "
f"response (top-level keys: {keys!r})"
)
raise AuthError(
f"simpliTV: no token in authenticate response "
f"(keys={keys!r})"
)
expires_in = (
_parse_expiry(data.get("tokenExpirationTime"))
or SimpliTVDefaults.TOKEN_LIFETIME_SECONDS
)
return SimpliTVAuthToken(
access_token=token_value,
token_type="token",
expires_in=expires_in,
issued_at=time.time(),
)
def _resolve_credentials(self) -> Dict[str, str]:
raw = self._credentials or self._load_stored_credentials()
if not raw:
raise CredentialsError("simpliTV: no credentials available")
if isinstance(raw, dict):
username, password = raw.get("username"), raw.get("password")
else:
username = getattr(raw, "username", None)
password = getattr(raw, "password", None)
if not username or not password:
raise CredentialsError("simpliTV: username or password empty")
return {"username": username, "password": password}
def _load_stored_credentials(self):
"""
Load credentials for this provider from the host.
Priority:
1. An injected settings_manager, via
get_provider_credentials(provider, country). The host's
provider registry currently instantiates providers WITHOUT
a settings_manager, so this path is usually a no-op -- but
honouring it keeps this provider compatible with any host
that does inject one.
2. CredentialManager directly, which reads credentials.json.
CredentialManager.load_credentials tries the country-nested
path first and then falls back to the flat path, so both
{"simpli": {"at": {...}}} and {"simpli": {...}} work.
Returns a BaseCredentials instance (or a dict, if a host
implementation returns one) on success, else None.
"""
provider = SimpliTVDefaults.PROVIDER_NAME
# 1. settings_manager (may be None).
sm = self.settings_manager
if sm is not None and hasattr(sm, "get_provider_credentials"):
try:
creds = sm.get_provider_credentials(provider, self.country)
if creds is not None:
logger.debug(
f"simpliTV: loaded credentials via settings_manager "
f"for {provider}[{self.country}]"
)
return creds
except Exception as e:
logger.debug(
f"simpliTV: settings_manager.get_provider_credentials "
f"failed for {provider}: {e}"
)
# 2. CredentialManager (credentials.json).
try:
from ...base.auth.credential_manager import CredentialManager
creds = CredentialManager().load_credentials(
provider, self.country
)
if creds is not None:
logger.debug(
f"simpliTV: loaded credentials via CredentialManager "
f"for {provider}[{self.country}]"
)
return creds
logger.debug(
f"simpliTV: no stored credentials found for "
f"{provider}[{self.country}]"
)
except Exception as e:
logger.debug(
f"simpliTV: CredentialManager lookup failed for "
f"{provider}: {e}"
)
return None
# ------------------------------------------------------------------
# Device list / registration
# ------------------------------------------------------------------
def _list_devices(self) -> list:
"""
GET /v1/Devices/GetDevices. Returns the raw device list (may be
empty; that is not an error).
with_token() runs outside transport_errors so a login failure
keeps its own type.
"""
url = self.with_token(
self.config.get_devices_url(),
{"platformCodename": self.config.platform_codename},
)
with transport_errors("GetDevices"):
resp = self.http_manager.get(
url, headers=self.config.get_api_headers()
)
devices = resp.json().get("devices")
return devices if isinstance(devices, list) else []
def _register_device(self, device_key: str) -> None:
"""
Register a device key with the server.
Payload matches the browser capture for RegisterDevice. The
response shape is not verified, so only an explicit
`result.success == false` is treated as a refusal; anything
else is accepted and the caller's GetDevices read-back decides
whether registration actually happened.
"""
logger.debug(
f"simpliTV[{self.country}]: registering device "
f"{_mask(device_key)}"
)
payload = {
"platformCodename": self.config.platform_codename,
"deviceKey": device_key,
"userToken": self.get_access_token(),
"pushToken": "",
"generalDeviceType": SimpliTVDefaults.DEVICE_GENERAL_TYPE,
"deviceName": SimpliTVDefaults.DEVICE_NAME,
"browserVersion": SimpliTVDefaults.DEVICE_BROWSER_VERSION,
"userAgent": self.config.user_agent,
"operatingSystem": SimpliTVDefaults.DEVICE_OS,
"versionOs": SimpliTVDefaults.DEVICE_OS_VERSION,
}
with transport_errors("RegisterDevice"):
resp = self.http_manager.post(
self.config.register_device_url(),
json=payload,
headers=self.build_headers(),
)
try:
data = resp.json()
except ValueError:
return # empty / non-JSON body: rely on the read-back
result = data.get("result") if isinstance(data, dict) else None
if isinstance(result, dict) and result.get("success") is False:
raise AuthError(
f"simpliTV: RegisterDevice refused (response: {data!r})"
)