mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-20 14:52:30 +02:00
226 lines
9.2 KiB
Python
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 |