diff --git a/lib/streaming_providers/providers/eon/auth.py b/lib/streaming_providers/providers/eon/auth.py index 2e3cffe..09b6ee5 100644 --- a/lib/streaming_providers/providers/eon/auth.py +++ b/lib/streaming_providers/providers/eon/auth.py @@ -4,8 +4,9 @@ Eon authentication -- the auth class itself satisfies AuthProtocol (get_access_token / build_headers / invalidate); managers get it directly. Three identities are involved (see PVREon / HttpClient): - * client token -- OAuth client_credentials at the broker; only used for - device registration. Re-fetched on 401. + * client token -- OAuth client_credentials at the broker; shared with + EonEndpoints (brand / CDN discovery) and used for device + registration. Owned by EonClientToken, re-fetched on 401. * device -- serial (client-generated), deviceId / deviceNumber (server-minted). CLIENT IDENTITY: persisted, never cleared with the session. @@ -59,7 +60,7 @@ from ...base.errors import AuthError, ProviderError, ServerError, error_from_exc from ...base.utils.logger import logger from ...base.utils.transport import transport_errors from .constants import EonConfig, EonDefaults -from .bootstrap import EonEndpoints +from .bootstrap import EonClientToken, EonEndpoints from .errors import EonLoginRejected, EonOtpRequiredError from .models import EonDeviceRegistration, EonTokenResponse @@ -118,6 +119,7 @@ class EonAuth: http_manager: Any, config: EonConfig, endpoints: EonEndpoints, + client_token: EonClientToken, store: Optional[EonStore] = None, credentials: Any = None, credentials_loader: Optional[Callable[[], Any]] = None, @@ -126,6 +128,7 @@ class EonAuth: self._http = http_manager self._config = config self._endpoints = endpoints + self._client_token = client_token self._store: EonStore = store if store is not None else MemoryStore() self._credentials = credentials # object with .username / .password self._credentials_loader = credentials_loader # lazy: no disk/network in __init__ @@ -133,10 +136,8 @@ class EonAuth: self._stream_user: Optional[str] = None self._access_token: Optional[str] = None self._expires_at: Optional[float] = None - self._generic_token: Optional[str] = None self._pending_otp: Optional[str] = None self._lock = threading.RLock() - self._generic_lock = threading.RLock() self._clock = clock self.last_error: Optional[Exception] = None self._permanent: Optional[Exception] = None @@ -385,29 +386,8 @@ class EonAuth: raise AuthError(f"{_PROVIDER}: the one-time code was not confirmed or has expired") from exc # ------------------------------------------------------------------ - # Client token + device registration + # Generic client-token calls + device registration # ------------------------------------------------------------------ - def _client_token(self, *, force: bool = False) -> str: - with self._generic_lock: - if self._generic_token and not force: - return self._generic_token - with transport_errors("client token", _PROVIDER): - try: - resp = self._http.post( - EonDefaults.BROKER_URL + EonDefaults.PATH_OAUTH_TOKEN, - params={"grant_type": "client_credentials"}, - json={}, - headers=self._config.basic_headers(), - operation="auth", - ) - token = resp.json().get("access_token") - except HTTPError as exc: - raise error_from_exception(exc) from exc - if not token: - raise ServerError(f"{_PROVIDER}: client token response has no access_token") - self._generic_token = token - return token - def _generic_call(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, body: Optional[Dict[str, Any]] = None) -> Any: """Call with the client token; one retry after refreshing it on 401. @@ -417,7 +397,7 @@ class EonAuth: raise NotImplementedError(f"http_manager has no {method}()") url = self._endpoints.url(path) for attempt in (1, 2): - headers = self._config.bearer_headers(self._client_token(force=attempt == 2)) + headers = self._config.bearer_headers(self._client_token.get(force=attempt == 2)) kwargs: Dict[str, Any] = {"headers": headers, "operation": "api", "raise_for_status": False} if params: kwargs["params"] = params diff --git a/lib/streaming_providers/providers/eon/bootstrap.py b/lib/streaming_providers/providers/eon/bootstrap.py index bc0bad0..1e87cd4 100644 --- a/lib/streaming_providers/providers/eon/bootstrap.py +++ b/lib/streaming_providers/providers/eon/bootstrap.py @@ -2,12 +2,15 @@ """ Lazy bootstrap data. Nothing here runs in __init__ (no network there). -EonEndpoints -- resolves the brand's API host via the broker (v2/brands -> - cdnIdentifier, v1/cdninfo -> domains.baseApi[selector]). - Needs NO login: broker calls carry the client credentials. -EonCatalog -- rendering profiles, streaming servers and the service-provider - id. These need a logged-in session, so they sit behind EonApi - and are cached with a TTL. +EonClientToken -- OAuth client_credentials token at the broker. Needed by the + broker calls (brand / CDN discovery) and by the device + registration; no user, no device involved. +EonEndpoints -- resolves the brand's API host via the broker (v2/brands -> + cdnIdentifier, v1/cdninfo -> domains.baseApi[selector]). + Needs the CLIENT TOKEN, no user login. +EonCatalog -- rendering profiles, streaming servers and the service-provider + id. These need a logged-in session, so they sit behind EonApi + and are cached with a TTL. Brand resolution is by IDENTIFIER, never by list position: the broker's list has changed before. An unknown brand falls back to the CDN the broker marks @@ -18,7 +21,9 @@ import threading import time from typing import Any, Dict, List, Optional, Tuple -from ...base.errors import ConfigurationError, ServerError +from requests.exceptions import HTTPError + +from ...base.errors import ConfigurationError, ServerError, error_from_exception from ...base.utils.logger import logger from ...base.utils.transport import transport_errors from .constants import EonConfig, EonDefaults @@ -27,11 +32,43 @@ from .models import EonRenderingProfile, EonServer _PROVIDER = EonDefaults.PROVIDER_LABEL -class EonEndpoints: +class EonClientToken: + """OAuth client_credentials token at the broker. No user, no device.""" + def __init__(self, *, http_manager: Any, config: EonConfig): self._http = http_manager self._config = config self._lock = threading.RLock() + self._token: Optional[str] = None + + def get(self, *, force: bool = False) -> str: + with self._lock: + if self._token and not force: + return self._token + with transport_errors("client token", _PROVIDER): + try: + resp = self._http.post( + EonDefaults.BROKER_URL + EonDefaults.PATH_OAUTH_TOKEN, + params={"grant_type": "client_credentials"}, + json={}, + headers=self._config.basic_headers(), + operation="auth", + ) + token = resp.json().get("access_token") + except HTTPError as exc: + raise error_from_exception(exc) from exc + if not token: + raise ServerError(f"{_PROVIDER}: client token response has no access_token") + self._token = token + return token + + +class EonEndpoints: + def __init__(self, *, http_manager: Any, config: EonConfig, client_token: EonClientToken): + self._http = http_manager + self._config = config + self._client_token = client_token + self._lock = threading.RLock() self._api_base: Optional[str] = None self._images_base: Optional[str] = None @@ -87,12 +124,18 @@ class EonEndpoints: def _broker_get(self, path: str, what: str) -> Any: with transport_errors(what, _PROVIDER): - resp = self._http.get( - EonDefaults.BROKER_URL + path, - headers=self._config.basic_headers(), - operation="api", - ) - return resp.json() + for attempt in (1, 2): + token = self._client_token.get(force=attempt == 2) + resp = self._http.get( + EonDefaults.BROKER_URL + path, + headers=self._config.bearer_headers(token), + operation="api", + raise_for_status=False, + ) + if resp.status_code != 401 or attempt == 2: + resp.raise_for_status() + return resp.json() + raise ServerError(f"{_PROVIDER}: unreachable") # pragma: no cover def _brand_cdn_identifier(self) -> str: brands = self._broker_get(EonDefaults.PATH_BRANDS, "brands") diff --git a/lib/streaming_providers/providers/eon/provider.py b/lib/streaming_providers/providers/eon/provider.py index 47a52b5..b511010 100644 --- a/lib/streaming_providers/providers/eon/provider.py +++ b/lib/streaming_providers/providers/eon/provider.py @@ -1,10 +1,11 @@ # streaming_providers/providers/eon/provider.py +# streaming_providers/providers/eon/provider.py """ Eon provider -- orchestrator only. -The provider owns the SHARED resources (config, http_manager, endpoints, -auth, api, catalog, caches) and BUILDS the managers. Everything identical -across providers is inherited from ManagedProvider. +The provider owns the SHARED resources (config, http_manager, client_token, +endpoints, auth, api, catalog, caches) and BUILDS the managers. Everything +identical across providers is inherited from ManagedProvider. Eon is a white-label platform: `config["brand"]` (vivacom, nova, sbb, telemach, telemach-hr, telemach-si) picks the operator, `config["platform"]` @@ -19,7 +20,7 @@ from ...base.managed_provider import ManagedProvider from ...base.models.proxy_models import ProxyConfig from .api import EonApi from .auth import EonAuth, EonStore -from .bootstrap import EonCatalog, EonEndpoints +from .bootstrap import EonCatalog, EonClientToken, EonEndpoints from .catchup_manager import EonCatchupManager from .channel_manager import EonChannelManager from .constants import EonConfig, EonDefaults @@ -80,12 +81,17 @@ class EonProvider(ManagedProvider): ) # 4. Lazy collaborators: no network in __init__. - self.endpoints = EonEndpoints( + self.client_token = EonClientToken( http_manager=self.http_manager, config=self.provider_config ) + self.endpoints = EonEndpoints( + http_manager=self.http_manager, config=self.provider_config, + client_token=self.client_token, + ) self.auth = EonAuth( http_manager=self.http_manager, config=self.provider_config, endpoints=self.endpoints, + client_token=self.client_token, store=store if store is not None else default_store, credentials_loader=credentials_loader, )