Files
script.service.ultimate/lib/streaming_providers/providers/eon/bootstrap.py
T
2026-10-19 14:28:32 +02:00

226 lines
9.2 KiB
Python

# streaming_providers/providers/eon/bootstrap.py
"""
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.
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 ...base.errors import ConfigurationError, ServerError
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 EonEndpoints:
def __init__(self, *, http_manager: Any, config: EonConfig):
self._http = http_manager
self._config = config
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):
resp = self._http.get(
EonDefaults.BROKER_URL + path,
headers=self._config.basic_headers(),
operation="api",
)
return resp.json()
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