mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-06 16:02:47 +02:00
386 lines
15 KiB
Python
386 lines
15 KiB
Python
# streaming_providers/providers/_template/auth.py
|
|
"""
|
|
{TODO: Provider name} authentication.
|
|
|
|
Implements the three shared Auth methods (get_access_token, build_headers,
|
|
invalidate), the credential surface (has_credentials, set_credentials,
|
|
clear_credentials), and any optional extensions the provider needs.
|
|
|
|
Auth is a protocol, not an ABC. See base/protocols.py for the runtime
|
|
shape; see ../_template/README.md ("The Auth protocol") for the contract.
|
|
|
|
Constructor contract (recommended, not enforced):
|
|
__init__(*, http_manager, country, settings_manager=None,
|
|
credentials=None, **provider_opts)
|
|
|
|
Credentials source priority (README, "Credentials"):
|
|
1. constructor argument (CLI, tests)
|
|
2. injected settings_manager (may be None -- the registry usually
|
|
constructs providers WITHOUT one)
|
|
3. CredentialManager (direct credentials.json read) -- the path that
|
|
actually works in the normal runtime
|
|
4. fallback credentials (anonymous/free tier), if any
|
|
Credentials are re-read on every authenticate, so a user who stores them
|
|
after the provider was constructed does not need an app restart.
|
|
|
|
Deviations to document HERE (module docstring) if your provider has them:
|
|
* Token NOT in a header (query param / body field): build_headers()
|
|
returns base headers only; callers attach the token via
|
|
with_token(url, param=...) / auth_body(). Put the param names in
|
|
constants.py (they can differ per endpoint) and add a one-line
|
|
comment at every manager call site that uses build_headers().
|
|
* Content-Type quirks (e.g. a login endpoint that needs text/plain with
|
|
a JSON body): send data=json.dumps(payload) with the header set for
|
|
THAT call only, and comment why, or someone will "fix" it.
|
|
|
|
Thread safety: the host is multi-threaded. get_access_token() and the
|
|
other stateful accessors hold an RLock so two threads on a cold cache do
|
|
not run the (multi-step) login twice. Providers whose login is a single
|
|
HTTP call can drop the lock.
|
|
"""
|
|
|
|
import threading
|
|
from typing import Any, Dict, Optional
|
|
|
|
from ...base.auth.credential_manager import CredentialManager
|
|
from ...base.auth.credentials import UserPasswordCredentials
|
|
from ...base.errors import CredentialsError
|
|
from ...base.utils.logger import logger
|
|
|
|
from .constants import YourDefaults
|
|
from .models import YourAuthToken
|
|
|
|
|
|
class YourProviderAuth:
|
|
"""
|
|
Authenticator for {TODO: provider name}.
|
|
|
|
Not a subclass of any base class -- matches AuthProtocol by shape.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
http_manager,
|
|
country: str,
|
|
settings_manager=None,
|
|
credentials=None,
|
|
config=None,
|
|
**provider_opts,
|
|
):
|
|
"""
|
|
Args:
|
|
http_manager: Shared HTTPManager instance (owned by provider).
|
|
country: Two-letter country code.
|
|
settings_manager: Base settings manager. May be None; the auth
|
|
class must work without it.
|
|
credentials: Pre-supplied credentials (source #1).
|
|
config: The provider's YourConfig (URLs, base headers).
|
|
**provider_opts: Provider-specific state (device_id,
|
|
client_version, platform, ...). Document what
|
|
you use; the base ignores everything here.
|
|
"""
|
|
self.http_manager = http_manager
|
|
self.country = country
|
|
self.settings_manager = settings_manager
|
|
self.config = config
|
|
self._credentials = credentials
|
|
self._lock = threading.RLock()
|
|
# TODO: store provider_opts you need, e.g.:
|
|
# self.device_id = provider_opts.get("device_id") or self._load_device_id()
|
|
|
|
# Optional persistence: restore a stored token (no network I/O).
|
|
self._cached_token = self._load_session()
|
|
|
|
# ------------------------------------------------------------------
|
|
# The three shared methods -- every provider implements these
|
|
# ------------------------------------------------------------------
|
|
|
|
def get_access_token(self, force_refresh: bool = False) -> str:
|
|
"""
|
|
Return the raw token string (no scheme prefix).
|
|
|
|
If a cached token exists and is not near expiry, return it.
|
|
Otherwise authenticate, cache, and return.
|
|
"""
|
|
with self._lock:
|
|
if (
|
|
not force_refresh
|
|
and self._cached_token
|
|
and not self._cached_token.is_expired
|
|
):
|
|
return self._cached_token.access_token
|
|
|
|
token = self._perform_authentication()
|
|
self._cached_token = token
|
|
self._save_session(token)
|
|
return token.access_token
|
|
|
|
def build_headers(
|
|
self, token: Optional[str] = None, **opts
|
|
) -> Dict[str, str]:
|
|
"""
|
|
Return request-ready headers.
|
|
|
|
If token is None, fetch it via get_access_token(). Providers add
|
|
their own non-auth headers (device id, client version, session
|
|
state, origin, referer) here.
|
|
"""
|
|
if token is None:
|
|
token = self.get_access_token()
|
|
|
|
headers = (
|
|
self.config.get_base_headers()
|
|
if self.config is not None
|
|
else {"Accept": "application/json"}
|
|
)
|
|
|
|
# TODO: pick the auth scheme your provider uses:
|
|
# MoveTV "X-Auth-Token": token
|
|
# Magenta "Bff_token": token
|
|
# RTL+ "Authorization": f"Bearer {token}"
|
|
# HRTi "authorization": f"Client {token}"
|
|
# Discovery "Authorization": f"Bearer {token}" + session headers
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
|
|
# TODO: add non-auth headers the API requires. Examples:
|
|
# "X-Device-Id": self.device_id
|
|
# "X-Client-Version": self.client_version
|
|
# "Origin": ..., "Referer": ...
|
|
# Discovery-style session state, Magenta-style guest ids, etc. also
|
|
# go here.
|
|
|
|
return headers
|
|
|
|
def invalidate(self) -> None:
|
|
"""
|
|
Drop cached token and session state. Called after 401s.
|
|
|
|
The next get_access_token() call must perform full
|
|
re-authentication. Callers: on a 401, call invalidate() and retry
|
|
the request once -- never in a loop.
|
|
"""
|
|
with self._lock:
|
|
self._cached_token = None
|
|
self._clear_session()
|
|
# TODO: clear provider-specific session state, e.g.:
|
|
# self._session_state = None
|
|
# self._cookies.clear()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Credential surface (providers with user credentials)
|
|
#
|
|
# Providers WITHOUT user credentials: has_credentials() returns True,
|
|
# set_/clear_credentials() are no-ops returning False, and
|
|
# _ensure_credentials() is not needed in _perform_authentication().
|
|
# ------------------------------------------------------------------
|
|
|
|
def has_credentials(self) -> bool:
|
|
"""True if this auth can authenticate right now."""
|
|
if self._credentials and self._credentials.validate():
|
|
return True
|
|
fresh = self._load_stored_credentials()
|
|
if fresh and fresh.validate():
|
|
return True
|
|
fallback = self.get_fallback_credentials()
|
|
return bool(fallback and fallback.validate())
|
|
|
|
def set_credentials(self, username: str, password: str) -> bool:
|
|
"""Persist credentials (called by the settings UI)."""
|
|
if not self.settings_manager:
|
|
return False
|
|
try:
|
|
self.settings_manager.save_provider_credentials(
|
|
YourDefaults.PROVIDER_NAME,
|
|
UserPasswordCredentials(username, password),
|
|
self.country,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Could not store credentials: {e}")
|
|
return False
|
|
with self._lock:
|
|
self._credentials = None # force a re-read on next login
|
|
self.invalidate()
|
|
return True
|
|
|
|
def clear_credentials(self) -> bool:
|
|
"""Clear stored credentials and drop the cached token."""
|
|
try:
|
|
if self.settings_manager:
|
|
self.settings_manager.clear_provider_credentials(
|
|
YourDefaults.PROVIDER_NAME, self.country
|
|
)
|
|
else:
|
|
CredentialManager().delete_credentials(
|
|
YourDefaults.PROVIDER_NAME, self.country
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Could not clear credentials: {e}")
|
|
return False
|
|
with self._lock:
|
|
self._credentials = None
|
|
self.invalidate()
|
|
return True
|
|
|
|
def get_fallback_credentials(self):
|
|
"""
|
|
Credentials for an anonymous / limited free tier, or None.
|
|
|
|
Override for providers that work without user configuration.
|
|
"""
|
|
return None
|
|
|
|
def _load_stored_credentials(self):
|
|
"""Sources #2 and #3: settings_manager first, then CredentialManager."""
|
|
if self.settings_manager and hasattr(
|
|
self.settings_manager, "get_provider_credentials"
|
|
):
|
|
try:
|
|
creds = self.settings_manager.get_provider_credentials(
|
|
YourDefaults.PROVIDER_NAME, self.country
|
|
)
|
|
if creds:
|
|
return creds
|
|
except Exception as e:
|
|
logger.debug(f"settings_manager credentials failed: {e}")
|
|
try:
|
|
# Covers both the country-nested and flat storage layouts.
|
|
return CredentialManager().load_credentials(
|
|
YourDefaults.PROVIDER_NAME, self.country
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"CredentialManager load failed: {e}")
|
|
return None
|
|
|
|
def _ensure_credentials(self) -> bool:
|
|
"""
|
|
Make self._credentials valid, re-reading storage if needed.
|
|
|
|
Call at the START of _perform_authentication(). Without it the
|
|
auth class silently depends on the caller having passed
|
|
credentials at construction -- which the registry never does.
|
|
"""
|
|
if self._credentials and self._credentials.validate():
|
|
return True
|
|
fresh = self._load_stored_credentials()
|
|
if fresh and fresh.validate():
|
|
self._credentials = fresh
|
|
return True
|
|
self._credentials = self.get_fallback_credentials()
|
|
return self._credentials is not None and self._credentials.validate()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Provider-specific implementation
|
|
# ------------------------------------------------------------------
|
|
|
|
def _perform_authentication(self) -> YourAuthToken:
|
|
"""
|
|
Do the actual login HTTP call and return a YourAuthToken.
|
|
|
|
(A concrete AuthToken subclass is mandatory: BaseAuthToken is an
|
|
ABC. See models.py.)
|
|
"""
|
|
if not self._ensure_credentials():
|
|
raise CredentialsError(
|
|
f"no credentials available for {YourDefaults.PROVIDER_NAME}"
|
|
)
|
|
payload = self._build_login_payload(self._credentials)
|
|
resp = self.http_manager.post(
|
|
self._login_url(), json=payload, headers=self._login_headers()
|
|
)
|
|
return self._create_token_from_response(resp.json())
|
|
|
|
def _login_url(self) -> str:
|
|
return self.config.login_url()
|
|
|
|
def _login_headers(self) -> Dict[str, str]:
|
|
# Base headers only: build_headers() would try to fetch a token.
|
|
return self.config.get_base_headers()
|
|
|
|
def _build_login_payload(self, credentials) -> Dict[str, Any]:
|
|
# Custom credentials classes provide to_auth_payload().
|
|
# TODO: adapt to your provider's login payload.
|
|
return {
|
|
"username": credentials.username,
|
|
"password": credentials.password,
|
|
}
|
|
|
|
def _create_token_from_response(self, data: Dict[str, Any]) -> YourAuthToken:
|
|
# TODO: parse the login response. Check base/auth/base_auth.py for
|
|
# any additional required BaseAuthToken fields.
|
|
raise NotImplementedError(
|
|
"YourProviderAuth._create_token_from_response"
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Session persistence (OPTIONAL)
|
|
#
|
|
# Skip it for providers with cheap re-auth (opaque token, no refresh
|
|
# flow -- simpliTV does). Keep it for expensive flows (multi-step,
|
|
# rate-limited, device codes). If you skip it, delete _load_session /
|
|
# _save_session / _clear_session and the call in __init__.
|
|
# ------------------------------------------------------------------
|
|
|
|
def _load_session(self) -> Optional[YourAuthToken]:
|
|
if not self.settings_manager:
|
|
return None
|
|
try:
|
|
stored = self.settings_manager.load_token_data(
|
|
YourDefaults.PROVIDER_NAME, self.country
|
|
)
|
|
return YourAuthToken.from_dict(stored) if stored else None
|
|
except Exception as e:
|
|
logger.debug(f"Could not restore stored token: {e}")
|
|
return None
|
|
|
|
def _save_session(self, token) -> None:
|
|
if not self.settings_manager:
|
|
return
|
|
try:
|
|
self.settings_manager.save_token_data(
|
|
YourDefaults.PROVIDER_NAME, token.to_dict(), self.country
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Could not persist token: {e}")
|
|
|
|
def _clear_session(self) -> None:
|
|
if not self.settings_manager:
|
|
return
|
|
try:
|
|
self.settings_manager.clear_token(
|
|
YourDefaults.PROVIDER_NAME, self.country
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Could not clear stored token: {e}")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Optional extensions -- uncomment and implement only if needed
|
|
# ------------------------------------------------------------------
|
|
|
|
# def get_scoped_token(self, scope: str, **opts) -> Optional[str]:
|
|
# """Secondary token for the given scope (RTL+: bedrock / upfront)."""
|
|
# return None
|
|
|
|
# def get_session_context(self) -> Optional[Dict[str, Any]]:
|
|
# """
|
|
# Opaque session state needed by build_headers. Magenta returns
|
|
# {"device_id": ..., "session_id": ...}; Discovery the current
|
|
# session headers.
|
|
# """
|
|
# return None
|
|
|
|
# def authorize_playback(
|
|
# self, content_id: str, **opts
|
|
# ) -> Dict[str, Any]:
|
|
# """
|
|
# Provider-specific pre-playback step (HRTi AuthorizeSession,
|
|
# MoveTV live-source fetch, Discovery playbackInfo POST, RTL+
|
|
# upfront token, Magenta persona JWT). No fixed interface; the
|
|
# name is a convention, the shape is provider-specific.
|
|
# """
|
|
# return {}
|
|
|
|
# def with_token(self, url: str, param: Optional[str] = None) -> str:
|
|
# """Token-in-URL providers: append the token. Take the parameter
|
|
# name from constants.py (YourDefaults.TOKEN_PARAM), never hardcode."""
|
|
# ... |