# streaming_providers/providers/eon/bootstrap.py """ Lazy bootstrap data. Nothing here runs in __init__ (no network there). 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 `isDefault` (shared by the ex-Yu brands), then to the global host. """ import threading import time from typing import Any, Dict, List, Optional, Tuple 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 from .models import EonRenderingProfile, EonServer _PROVIDER = EonDefaults.PROVIDER_LABEL 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 # ------------------------------------------------------------------ @property def api_base(self) -> str: self._ensure() return self._api_base # type: ignore[return-value] @property def images_base(self) -> str: """Host for logos / programme images (same CDN as the API).""" self._ensure() return self._images_base # type: ignore[return-value] def _ensure(self) -> None: if self._api_base is None: with self._lock: if self._api_base is None: self._api_base, self._images_base = self._resolve() def reset(self) -> None: """Forget the resolved host (e.g. after repeated connection failures).""" with self._lock: self._api_base = None self._images_base = None def url(self, path: str) -> str: return f"{self.api_base}{path}" def image_url(self, path: Optional[str]) -> Optional[str]: """Image paths start with `/`, the base ends with one: join with a single slash (the addon concatenated them, giving `//`).""" if not path: return None return self.images_base.rstrip("/") + "/" + path.lstrip("/") # ------------------------------------------------------------------ def _resolve(self) -> Tuple[str, str]: cdn_identifier = self._brand_cdn_identifier() base_api = self._cdn_base_api(cdn_identifier) prefix = self._config.platform.api_prefix if base_api: api = f"https://api-{prefix}.{base_api}/" images = f"https://images-{prefix}.{base_api}/" else: # GLOBAL_HOST already ends with its own slash. api = f"https://api-{prefix}.{EonDefaults.GLOBAL_HOST}" images = f"https://images-{prefix}.{EonDefaults.GLOBAL_HOST}" logger.warning(f"{_PROVIDER}: no CDN base API found; using global host") logger.info(f"{_PROVIDER}: API host resolved ({self._config.brand})") return api, images def _broker_get(self, path: str, what: str) -> Any: with transport_errors(what, _PROVIDER): 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") if not isinstance(brands, list): raise ServerError(f"{_PROVIDER}: brands response is not a list") for brand in brands: if isinstance(brand, dict) and brand.get("identifier") == self._config.brand_identifier: return brand.get("cdnIdentifier") or "" logger.warning( f"{_PROVIDER}: brand {self._config.brand_identifier!r} not in the " f"broker's list; using the broker's default CDN" ) return "" def _cdn_base_api(self, cdn_identifier: str) -> str: cdns = self._broker_get(EonDefaults.PATH_CDNINFO, "cdninfo") if not isinstance(cdns, list): raise ServerError(f"{_PROVIDER}: cdninfo response is not a list") selector = self._config.platform.api_selector default = "" for cdn in cdns: if not isinstance(cdn, dict): continue base = ((cdn.get("domains") or {}).get("baseApi") or {}).get(selector) or "" if cdn_identifier and cdn.get("identifier") == cdn_identifier and base: return base if cdn.get("isDefault") and base: default = base if default: logger.warning(f"{_PROVIDER}: no CDN matched; using the broker's default CDN") return default class EonCatalog: def __init__(self, *, api: Any, config: EonConfig): self._api = api self._config = config self._lock = threading.RLock() self._loaded_at = 0.0 self._service_provider = "" self._profiles: Dict[int, EonRenderingProfile] = {} self._live_servers: List[EonServer] = [] self._timeshift_servers: List[EonServer] = [] def invalidate(self) -> None: with self._lock: self._loaded_at = 0.0 # ------------------------------------------------------------------ def _ensure(self) -> None: if self._loaded_at and time.time() - self._loaded_at < EonDefaults.CATALOG_TTL_SECONDS: return with self._lock: if self._loaded_at and time.time() - self._loaded_at < EonDefaults.CATALOG_TTL_SECONDS: return self._load() def _load(self) -> None: sp = self._api.get_json(EonDefaults.PATH_SERVICE_PROVIDER) service_provider = sp.get("identifier") if isinstance(sp, dict) else None if not service_provider: raise ServerError(f"{_PROVIDER}: service provider response has no identifier") raw_profiles = self._api.get_json(EonDefaults.PATH_PROFILES) if not isinstance(raw_profiles, list): raise ServerError(f"{_PROVIDER}: rendering profiles response is not a list") profiles: Dict[int, EonRenderingProfile] = {} for entry in raw_profiles: try: p = EonRenderingProfile.from_api_response(entry) profiles[p.id] = p except (KeyError, TypeError, ValueError) as exc: logger.warning(f"{_PROVIDER}: skipping malformed rendering profile: {exc!r}") raw_servers = self._api.get_json(EonDefaults.PATH_SERVERS) if not isinstance(raw_servers, dict): raise ServerError(f"{_PROVIDER}: servers response is not an object") self._service_provider = service_provider self._profiles = profiles self._live_servers = self._parse_servers(raw_servers.get("live_servers")) self._timeshift_servers = self._parse_servers(raw_servers.get("timeshift_servers")) self._loaded_at = time.time() @staticmethod def _parse_servers(raw: Any) -> List[EonServer]: servers: List[EonServer] = [] for entry in raw or []: try: servers.append(EonServer.from_api_response(entry)) except (KeyError, TypeError) as exc: logger.warning(f"{_PROVIDER}: skipping malformed server entry: {exc!r}") return servers # ------------------------------------------------------------------ @property def service_provider(self) -> str: self._ensure() return self._service_provider def best_profile(self, profile_ids: List[int], *, radio: bool) -> EonRenderingProfile: """Highest-bitrate profile among the channel's ids (audio bitrate for radio, video bitrate otherwise).""" self._ensure() best: Optional[EonRenderingProfile] = None best_rate = 0 for pid in profile_ids: profile = self._profiles.get(pid) if not profile: continue rate = profile.audio_bitrate if radio else profile.video_bitrate if rate > best_rate: best, best_rate = profile, rate if best is None: raise ServerError(f"{_PROVIDER}: no usable rendering profile for ids {profile_ids}") return best def server(self, *, live: bool) -> EonServer: """The Nth server of the list (positional, as the addon did).""" self._ensure() servers = self._live_servers if live else self._timeshift_servers index = self._config.server_index if len(servers) < index: raise ServerError( f"{_PROVIDER}: expected at least {index} " f"{'live' if live else 'timeshift'} servers, got {len(servers)}" ) server = servers[index - 1] if not server.hostname: raise ServerError(f"{_PROVIDER}: server #{index} has no hostname") return server