Files
script.service.ultimate/lib/streaming_providers/providers/eon/bootstrap.py
T
2026-10-20 08:01:04 +02:00

269 lines
11 KiB
Python

# 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