add provider eon

This commit is contained in:
Nirvana
2026-10-19 14:28:32 +02:00
parent 22fcfee44b
commit bbee42e89b
13 changed files with 2191 additions and 1 deletions
@@ -227,6 +227,14 @@ directory name == AVAILABLE_PROVIDERS key == provider_name == PROVIDER_NAME
proxy apply that v1 never found.
* `Content.country` / `Channel.country` are **upper-case** (`"RS"`), the manager's
`country` collaborator is lower-case: use `self.country.upper()` for models.
* **White-label platforms: the operator ("brand") is not the country.** Eon serves
six operators (SBB, Telemach ×3, Vivacom, Nova) from one API; brands and countries
are not 1:1. Make the brand a **config option** (`config["brand"]`, validated in
`EonConfig`, unknown value → `ConfigurationError`), keep `SUPPORTED_COUNTRIES` for
the default operator, and resolve brand → host by **identifier** from the platform's
own discovery endpoint, never by list position (the broker's list changed once and a
position then silently meant another operator's CDN). An identifier the broker no
longer lists falls back to the broker's own default, with a warning — not to a guess.
### How the registry constructs providers — check, don't assume
@@ -377,6 +385,19 @@ silent (HRTi: a stale device id made EPG return `[]` for as long as nobody looke
so every persisted identity value needs both getter **and** setter on the storage
layer (§12.1b).
**Bootstrap data splits in two (Eon).** Platforms that discover their API host first
(broker → CDN → host) and then need catalogues (rendering profiles, server list,
service-provider id) have a circular dependency if one object does both: auth needs the
host, the catalogues need auth. Split by *what the call needs*:
* `Endpoints` — host discovery only; **no login** (client credentials), lazy, resolved
once under a lock, with `reset()`. Auth, the API helper and the managers get it.
* `Catalog` — data that needs a session; fetched through the authenticated API helper,
cached with a TTL under a lock, **loaded on first use** (never in `__init__`). Two
consumers (live + catchup) already justify the collaborator (threshold above).
Neither runs network code in `__init__` (§9 rule); a test builds the provider with a
fake HTTP manager and asserts **zero calls** after construction.
**Dead dependencies.** A declared keyword-only extra that no method reads is
deleted, including its comment. An extra that is passed "for later" is a
back-reference candidate nobody has reviewed.
@@ -518,6 +539,21 @@ the `Channel` where you know it at list time (§13).
* A failed request RAISES; "no programmes" (`success=false`, empty list) is `[]`
(decision rule in §8, **including its boundary**: session/identity-state errors
always raise, whatever the endpoint's classification).
* **Day-based endpoints** (Eon `v2/events/epg?cid=&date=`, one channel and one day per
request). Work out *which* day first, from a real response: the first event of
`date=2026-10-22` started at 00:15 **UTC** (03:15 local), i.e. a UTC day selected by
programme **start** time (one sample — confirm with a programme that crosses the
boundary before relying on it for another operator). Consequences: (1) a programme that began before the day
boundary and still runs is only in the **previous** day's answer, so request
`day(start) − 1 … day(end − 1)` and filter with `overlaps_with(start, end)`;
(2) de-duplicate by the programme id across days; (3) clamp to the archive, and let
**only the oldest day** tolerate a `400` (the boundary is day-granular; a `400` on any
other day is a real error and raises); (4) skip entries `EPGEntry` rejects
(`ValueError`: empty title, `end <= start`) and entries whose `channelId` is not the
one asked for, each with a warning. Derive the cost up front: *days × channels*
requests for a grid.
* **Timestamps in milliseconds** (`startTime`/`endTime`): divide by 1000 once, in the
model parser; `EPGEntry` takes seconds.
### 6.4 `RecordingsManager`
@@ -598,6 +634,18 @@ the `Channel` where you know it at list time (§13).
call is swallowed by the DRM pipeline (§8), so test the manifest-header path.
* Catchup that needs other managers takes them as keyword-only extras
(`channels=`, `epg=`, a shared source cache) — still no provider reference (§5.1).
* **The window can be per channel and server-supplied** (Eon: `cutvDelay` in ms on every
channel of the list, `cutvEnabled` as the switch). Keep `catchup_window_hours` a
**constant maximum with no I/O** (the property is read without a channel and must not
trigger a request) and read the real value in `catchup_window_for_channel()`; enforce
it in `get_catchup_manifest` (outside the window, in the future, `start_time <= 0` or a
channel without catchup → `None`).
* **Catchup that is "the live URL with a time" mints through the channel manager.**
Eon's catchup URL is the signed live URL plus `t=<start>000` and a different server
list. Put the minting in **one public method of the channel manager**
(`stream_url(record, start_time=None | int)`) and give the catchup manager
`channels=`; do not copy the signing code. `end_time` / `epg_id` the API does not use
are accepted for the signature and documented as ignored.
### 6.7 `DRM`
@@ -911,6 +959,40 @@ movetv "server-validated token" pattern generalizes to a ladder:
→ full refresh; both fail → `AuthError` with bounded call counts; N concurrent
callers → one repair.
### Several identities in one provider (Eon)
Some platforms need three different credentials, with different lifecycles. Keep them
apart in one auth class instead of one `token` field:
| Identity | Obtained by | Lifecycle | Cleared by `invalidate()`? |
|---|---|---|---|
| **client token** | OAuth `client_credentials` with the app's client id/secret | refetched on a `401` | no (not account state) |
| **device** (serial, device id, device number) | client-generated serial; server-minted ids at registration | persisted at creation (§5.1) | **never** |
| **user session** (access / refresh token, stream key + user) | refresh grant, else password / OTP login | server-validated (below) | yes |
* The user session is **server-validated** (no usable expiry, or none sent): treat the
token as valid until a `401`, apply the recovery ladder once, retry once. Eon's ladder
is: refresh-token grant → password / OTP login → **re-register the device** and log in
again → `AuthError`. A rejected refresh token is deleted before falling through.
* **The refresh grant rotates the access token**, so the id-comparison dedup under the
lock works here (the caller compares the token it failed with against the current
one). Contrast HRTi's registration, which does not rotate and needs a time window.
* **A login that needs a human step is a typed, non-retryable `AuthError` subclass**
carrying the data the UI must show (`EonOtpRequiredError(otp, expires_minutes)`). The
ladder must not swallow it, the next attempt exchanges the confirmed code, and a code
that was not confirmed produces a plain `AuthError` and clears the pending code.
* **Record a login failure once, after the whole ladder** (`_gated`). A rejected grant
*inside* the ladder is a signal (stale device → re-register), not a verdict; only the
ladder's final failure counts. Then apply the Variant B policy (`session_adapter.py`):
wrong credentials (`EonLoginRejected`, only HTTP 400/401 — a 403 may be a WAF) are
**permanent** until `set_credentials()`; everything else is **transient** with
exponential backoff (30 s … 900 s) and **no network while backing off**; a pending OTP
is neither. Without this every failing call retried a full login against the SSO.
* Persisted state is behind a small `Store` protocol (`get/set/delete`) with a
non-durable in-memory default **that is loudly documented as such**: without a durable
store every restart registers a new device (§12.1b).
* Never log the stream key, tokens, device serial or a signed URL — not even at DEBUG.
### Credentials API on the provider (OPTIONAL — only if v1 has it)
**A migration preserves the v1 public surface; it never adds to it.** Of the two
@@ -1072,6 +1154,35 @@ media-proxy path yet):
| Only the first variant is probed | separately encrypted audio renditions are not checked |
| `index.m3u8` alias | channels only; events, VOD and recordings still expose `index.mpd` |
**Locally minted, short-lived manifest URLs (Eon).** When the provider builds the
manifest URL itself (an AES-encrypted payload with a server `ctime` and a session id):
* The URL is valid for seconds (Eon's CDN rejects a `ctime` older than ~20 s) and the
session id must not be reused while the CDN thinks it is active. Mint per request
with a fresh session id; cache **only** for a few seconds, keyed by
`(content_id, start_time)` — a live URL must never be served for a catchup key.
* **Session-capped CDNs and the sniff (verified against `drm_operations.py`).** Every GET
on such a URL may open a CDN session and the CDN caps them (Eon: `403.5300 Streamable
bandwidth exceeded`). What the backend does per `(provider, content_id, drm kwargs)`:
it mints a URL (`get_manifest_with_headers`), GETs the playlist with
`provider.http_manager` and, for a **master** playlist, GETs the **first variant** too
(`_resolve_hls_master_encryption`); a verified-clear result (`[NONE]`) is cached for
`cache_ttl` (default 1 h). The player's own request then mints a second URL. So a
first tune-in costs ~3 GETs on the CDN, a repeat within the hour 1.
* **The `streaming_format` hint does not skip the sniff** — it only drives playlist
generation (above).
* **A failed sniff is not cached** (empty configs = "no solution", self-heals by
design), so a provider that is *refused* by the CDN (cap reached) is re-sniffed on
every request and makes it worse. Check the logs for repeated sniff GETs.
* **`inherit_manifest_query_on_segments`** (default `True`) appends the master's query
to the variant URL for that second GET. If the master query is a token the variant
does not need (Eon: the encrypted payload), set it `False` on the provider.
* Counting the real GETs on a device (§12.7) remains the proof.
* Keep the cryptography in one pure module (`stream_url.py`: no network, no state, the
IV injectable) and test it with a round trip **and** an exact-payload test per
platform variant — layouts differ per client and a field in the wrong place is a
`403` with no explanation.
---
## 12. Migrating an existing (v1) provider
@@ -1478,6 +1589,25 @@ def test_contract(key):
(`AuthError` / `RateLimitError` out of the retry loop, call count 1).
* Docstring promises have tests (Traps 26, 40).
### Eon: a scripted fake HTTP manager as the pattern
The Eon suite (`tests/test_eon.py`, `test_e2e.py`, `test_epg_catchup.py`) runs the whole
provider offline against a **route table**: `[(("get", "<url fragment>"), Resp | callable)]`
where a callable receives the call's kwargs (so a test can answer per `channelType`,
per `date` or per `cid`, and record what was asked). Like the real HTTP manager the fake
**raises `HTTPError` for ≥ 400 unless `raise_for_status=False`**. What it let us assert
cheaply and what you should copy:
* zero HTTP calls after constructing the provider;
* the exact **sequence** of calls of a login (client token → device registration →
token grant), the recovery ladder rung by rung (a `400` on the grant → device
re-registered → second grant) and that a stale-token `recover()` makes **no** call;
* per-request kwargs (the headers of the EPG call, the `params` of the channel list);
* that the **real** base ABCs are used, not stubs: copy `base/managers/*.py` and
`base/models/epg_models.py` into the sandbox — the ABC contract (abstract methods,
`ISOLATE_GRID_FAILURES`, `EPGEntry` validation) then fails the test instead of the
device. Stub only what you do not have (`ManagedProvider`, `transport_errors`).
### How the shipped suite was built (context)
72 tests ran in a sandbox: real files for the models/ABCs/ManagedProvider, **stubs**
@@ -1615,6 +1745,28 @@ Things that already cost time. Each is covered by a test or the contract unless
provider (§11).
69. Treating `METHOD=AES-128` HLS as clear (it is *unknown* to the backend) or
returning DRM configs for it (§11).
70. Brand/operator modelled as country, or resolved by list position (§4).
71. One object that does host discovery and authenticated catalogues — circular; split
`Endpoints` / `Catalog` (§5.1).
72. A day-based EPG endpoint asked for the window's days only: the programme crossing the
day boundary is in the previous day's answer (§6.3). Check the day's timezone on a
real response before writing the loop.
73. A catchup window read from the network inside `catchup_window_hours` (§6.6).
74. Signing code copied into the catchup manager instead of one public method on the
channel manager (§6.6).
75. Caching a minted URL longer than the CDN accepts its timestamp, or under a key that
ignores the start time (§11).
76. Not counting the backend's own manifest GETs against a session-capped CDN (§11).
77. A human-step login (OTP) flattened into a plain `AuthError`, or retried by the
ladder (§9).
78. Using a media-metric field from the wrong side: Eon's `minvbr`/`maxvbr` are **video**
bounds and must come from `videoBitrate` even on radio — an audio-only profile (video
bitrate 0) sent its audio bitrate and the CDN answered `403`. Profile *choice* uses
the audio bitrate for radio, the *bounds* never do. (Provider-specific, but the
general rule holds: a field's name says which quantity, not which channel type.)
79. Recording a login failure inside the recovery ladder (a stale-device `400` then
blocks the provider permanently) or treating every `AuthError` — including a WAF
`403` — as "wrong credentials" (§9).
---
@@ -1643,6 +1795,19 @@ Known gaps in the base, so you don't hunt for them:
geo-proxied providers do not play), AES-128 policy undecided, encrypted HLS only
passed through, only the first variant probed, `index.m3u8` alias for channels
only (§11).
* **`HTTPManager` has no `patch()`** (`_make_request("PATCH", …)` exists, only the public
wrapper is missing). Eon needs `PATCH v2/devices`; until it is added the provider
`hasattr`-checks and falls back to the `v1` POST (which mints a *new* device number).
`http_manager_patch.diff` (4 lines, not retried on 429 because PATCH is not in
`_IDEMPOTENT_METHODS`) adds it. Raw/multipart bodies already work: `post(url, data=<bytes>,
headers={"Content-Type": "multipart/form-data; boundary=…"})`, and `json=` passes
through `**kwargs`.
* **No shared persisted-state protocol for Variant A providers**: Eon ships its own
`EonStore` (`get/set/delete`) with an in-memory default; the session layer should offer
the same shape so identity survives restarts without per-provider glue.
* **`CatchupManager` and `EpgManager` do not say who owns "the programme at a time"** for
day-based endpoints; Eon's `get_epg` re-filters by overlap, a shared helper would
remove that duplication (see the deliberately-not-added `get_entry_at`).
**Tooling still to ship** (the README above already assumes it):
@@ -1816,4 +1981,20 @@ wiring snippets. It never edits your wiring and never registers anything.
* **Legacy surface** — what the operations layer calls (§3).
* **Variant A / B** — native `AuthProtocol` class / session adapter (§9).
* **StreamFormat** — `DASH` / `HLS` (`base/stream_format.py`); `None` = unknown. The
`streaming_format` field is only a hint; the backend sniffs the manifest (§11).
`streaming_format` field is only a hint; the backend sniffs the manifest (§11).
### 19.5 Eon — what it is the reference for
`providers/eon/` (live TV + radio, EPG, catchup, clear HLS, no DRM). Use it when you
meet one of these:
| Situation | Where | README |
|---|---|---|
| white-label platform, brand ≠ country, discovery before login | `constants.py` (`EonConfig`), `bootstrap.py` (`EonEndpoints`) | §4, §5.1 |
| catalogues that need a session, TTL + lock | `bootstrap.py` (`EonCatalog`) | §5.1 |
| client token + device + user session, recovery ladder, OTP | `auth.py`, `errors.py` | §9 |
| one place for "401 → recover → retry once" | `api.py` | §8, §9 |
| locally signed, short-lived manifest URL | `stream_url.py`, `channel_manager.py` | §11 |
| day-based EPG with a boundary programme | `epg_manager.py` | §6.3 |
| per-channel, server-supplied catchup window; catchup via the channel manager | `catchup_manager.py` | §6.6 |
| offline tests with a route-table fake and the real ABCs | `tests/` | §15 |
@@ -0,0 +1,4 @@
# streaming_providers/providers/eon/__init__.py
from .provider import EonProvider
__all__ = ["EonProvider"]
@@ -0,0 +1,63 @@
# streaming_providers/providers/eon/api.py
"""
Authenticated JSON access to the Eon API with the 401 recovery ladder.
One place owns "call, on 401 recover the session once, retry once": managers
never write their own retry. Everything else is mapped to typed errors via
base.errors.error_from_exception.
"""
from typing import Any, Dict, Optional
from requests.exceptions import HTTPError
from ...base.errors import ProviderError, ServerError, error_from_exception
from .constants import EonConfig, EonDefaults
from .bootstrap import EonEndpoints
_PROVIDER = EonDefaults.PROVIDER_LABEL
class EonApi:
def __init__(self, *, http_manager: Any, auth: Any, endpoints: EonEndpoints, config: EonConfig):
self._http = http_manager
self._auth = auth
self._endpoints = endpoints
self._config = config
def get_json(
self,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
extra_headers: Optional[Dict[str, str]] = None,
operation: str = "api",
) -> Any:
url = self._endpoints.url(path)
for attempt in (1, 2):
token = self._auth.get_access_token()
headers = {**self._config.bearer_headers(token), **(extra_headers or {})}
try:
resp = self._http.get(url, params=params, headers=headers, operation=operation)
return resp.json()
except ProviderError:
raise
except HTTPError as exc:
status = exc.response.status_code if exc.response is not None else None
if status == 401 and attempt == 1:
self._auth.recover(token)
continue
raise error_from_exception(exc) from exc
except ValueError as exc: # body is not JSON
raise ServerError(f"{_PROVIDER}: non-JSON response from {path}") from exc
except Exception as exc:
raise error_from_exception(exc) from exc
raise ServerError(f"{_PROVIDER}: unreachable") # pragma: no cover
def server_time(self) -> str:
"""Server clock in ms, as the string the CDN payload expects."""
data = self.get_json(EonDefaults.PATH_TIME)
value = data.get("time") if isinstance(data, dict) else None
if not value:
raise ServerError(f"{_PROVIDER}: time response has no `time`")
return str(value)
@@ -0,0 +1,492 @@
# streaming_providers/providers/eon/auth.py
"""
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.
* device -- serial (client-generated), deviceId / deviceNumber
(server-minted). CLIENT IDENTITY: persisted, never
cleared with the session.
* user session -- access / refresh token + stream key + stream user. The
stream key is also the AES key of the CDN URL.
Login flows (platform-dependent):
web refresh_token grant, else password grant (username SHA-256 in
upper-case hex + password + deviceNumber, multipart form)
androidtv refresh_token grant, else OTP: the server hands out a code the
user confirms in the operator's portal (EonOtpRequiredError),
the next attempt exchanges it.
Recovery ladder on a rejected token (recover()), each rung once:
1. refresh_token grant 2. password / OTP login 3. re-register the
device (new deviceNumber) and log in again -> else AuthError.
Failure policy (same as the Variant B session adapter, README §9): a failed
login is recorded ONCE, after the whole ladder has run --
* EonLoginRejected (wrong credentials) -> PERMANENT: no network until
set_credentials() / reset();
* EonOtpRequiredError -> a pending human step, never
gated (the next call exchanges the confirmed code);
* anything else (network, 5xx, WAF 403) -> TRANSIENT: exponential backoff
BACKOFF_BASE * 2^(n-1) s, capped. A rejected grant INSIDE the ladder is a
signal (stale device), not a verdict, and is not recorded.
`last_error` keeps the original exception for the auth-status UI.
Rules: lazy (no network in __init__), one login for concurrent callers,
typed errors only, nothing secret in logs (lengths at most).
"""
import hashlib
import json
import secrets
import string
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional, Protocol, Type
from requests.exceptions import HTTPError
from ...base.errors import AuthError, ProviderError, ServerError, error_from_exception
from ...base.utils.logger import logger
from ...base.utils.transport import transport_errors
from .constants import EonConfig, EonDefaults
from .bootstrap import EonEndpoints
from .errors import EonLoginRejected, EonOtpRequiredError
from .models import EonDeviceRegistration, EonTokenResponse
_PROVIDER = EonDefaults.PROVIDER_LABEL
K_DEVICE_SERIAL = "device_serial"
K_DEVICE_ID = "device_id"
K_DEVICE_NUMBER = "device_number"
K_REFRESH_TOKEN = "refresh_token"
K_STREAM_KEY = "stream_key"
K_STREAM_USER = "stream_user"
_SERIAL_ALPHABET = string.digits + string.ascii_letters + "-"
_TOKEN_REFRESH_BUFFER_SECONDS = 60
BACKOFF_BASE_SECONDS = 30
BACKOFF_MAX_SECONDS = 900
class EonStore(Protocol):
"""Durable key/value state owned by the backend's session layer."""
def get(self, key: str) -> Optional[str]: ...
def set(self, key: str, value: str) -> None: ...
def delete(self, key: str) -> None: ...
class MemoryStore:
"""Non-durable default. With it every restart registers a NEW device."""
def __init__(self) -> None:
self._data: Dict[str, str] = {}
def get(self, key: str) -> Optional[str]:
return self._data.get(key)
def set(self, key: str, value: str) -> None:
self._data[key] = value
def delete(self, key: str) -> None:
self._data.pop(key, None)
@dataclass(frozen=True)
class StreamCredentials:
stream_key: str
stream_user: str
device_number: str
class EonAuth:
def __init__(
self,
*,
http_manager: Any,
config: EonConfig,
endpoints: EonEndpoints,
store: Optional[EonStore] = None,
credentials: Any = None,
clock: Callable[[], float] = time.monotonic,
):
self._http = http_manager
self._config = config
self._endpoints = endpoints
self._store: EonStore = store if store is not None else MemoryStore()
self._credentials = credentials # object with .username / .password
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
self._failures = 0
self._retry_at = 0.0
# ------------------------------------------------------------------
# Credentials
# ------------------------------------------------------------------
@property
def has_credentials(self) -> bool:
c = self._credentials
return bool(c and getattr(c, "username", None) and getattr(c, "password", None))
def set_credentials(self, credentials: Any) -> None:
"""Replace the credentials, drop the session and forget any failure."""
self._credentials = credentials
self.invalidate()
self.reset()
def reset(self) -> None:
"""Forget recorded failures (permanent verdict and backoff)."""
with self._lock:
self.last_error = None
self._permanent = None
self._failures = 0
self._retry_at = 0.0
def details(self) -> Dict[str, Any]:
"""Free-form details for the auth-status UI."""
details: Dict[str, Any] = {}
if self.last_error is not None:
details["last_error"] = str(self.last_error)
details["last_error_type"] = type(self.last_error).__name__
if isinstance(self.last_error, EonOtpRequiredError):
details["otp"] = self.last_error.otp
details["otp_expires_minutes"] = self.last_error.expires_minutes
return details
# ------------------------------------------------------------------
# AuthProtocol
# ------------------------------------------------------------------
def get_access_token(self, force_refresh: bool = False) -> str:
if not force_refresh and self._token_valid():
return self._access_token # type: ignore[return-value]
with self._lock:
if not force_refresh and self._token_valid(): # another thread logged in
return self._access_token # type: ignore[return-value]
self._gated(self._login)
return self._access_token # type: ignore[return-value]
def build_headers(self) -> Dict[str, str]:
"""API headers incl. the bearer token (not for CDN requests)."""
return self._config.bearer_headers(self.get_access_token())
def invalidate(self) -> None:
"""Drop the SESSION (tokens, stream credentials). The device stays."""
with self._lock:
self._access_token = None
self._expires_at = None
self._pending_otp = None
for key in (K_REFRESH_TOKEN, K_STREAM_KEY, K_STREAM_USER):
self._store.delete(key)
# ------------------------------------------------------------------
# Eon-specific
# ------------------------------------------------------------------
def stream_credentials(self) -> StreamCredentials:
"""Stream key / user / device number, logging in first if needed."""
self.get_access_token()
key = self._store.get(K_STREAM_KEY)
user = self._store.get(K_STREAM_USER)
number = self._store.get(K_DEVICE_NUMBER)
if not (key and user and number):
raise ServerError(f"{_PROVIDER}: the session carries no stream credentials")
return StreamCredentials(stream_key=key, stream_user=user, device_number=number)
def recover(self, failed_token: Optional[str]) -> None:
"""Repair the session after a 401 with `failed_token`."""
with self._lock:
if self._access_token and self._access_token != failed_token:
return # another thread already repaired it
self._access_token = None
self._expires_at = None
self._gated(self._ladder)
def _ladder(self) -> None:
try:
self._login() # rungs 1 + 2
return
except EonOtpRequiredError:
raise
except AuthError:
logger.warning(f"{_PROVIDER}: login failed; re-registering the device")
self._reset_device() # rung 3
self._login() # AuthError propagates
def _gated(self, action: Callable[[], None]) -> None:
"""Run a login action behind the failure gate and record its outcome."""
if self._permanent is not None:
raise self._permanent
if self._clock() < self._retry_at and self.last_error is not None:
raise self.last_error
try:
action()
except EonOtpRequiredError as exc:
self.last_error = exc # a pending human step: not a failure
raise
except Exception as exc:
self._record_failure(exc)
raise
self.last_error = None
self._failures = 0
self._retry_at = 0.0
def _record_failure(self, exc: Exception) -> None:
self.last_error = exc
if isinstance(exc, EonLoginRejected):
self._permanent = exc
return
self._failures += 1
delay = min(BACKOFF_BASE_SECONDS * 2 ** (self._failures - 1), BACKOFF_MAX_SECONDS)
self._retry_at = self._clock() + delay
# ------------------------------------------------------------------
# Login
# ------------------------------------------------------------------
def _token_valid(self) -> bool:
if not self._access_token:
return False
if self._expires_at is None: # the server never told us: 401 decides
return True
return time.time() < self._expires_at - _TOKEN_REFRESH_BUFFER_SECONDS
def _login(self) -> None:
refresh = self._store.get(K_REFRESH_TOKEN)
if refresh:
try:
self._apply(self._grant({"grant_type": "refresh_token", "refresh_token": refresh}))
return
except AuthError:
logger.info(f"{_PROVIDER}: refresh token rejected")
self._store.delete(K_REFRESH_TOKEN)
if self._config.platform.is_android_tv:
self._login_otp()
return
if not self.has_credentials:
raise AuthError(
f"{_PROVIDER}: no credentials configured. "
f"Set username and password in the addon settings."
)
self._ensure_device()
self._apply(self._grant_password())
logger.info(f"{_PROVIDER}: authenticated")
def _apply(self, tok: EonTokenResponse) -> None:
self._access_token = tok.access_token
self._expires_at = time.time() + tok.expires_in if tok.expires_in else None
for key, value in (
(K_REFRESH_TOKEN, tok.refresh_token),
(K_STREAM_KEY, tok.stream_key),
(K_STREAM_USER, tok.stream_user),
(K_DEVICE_NUMBER, tok.device_number),
):
if value:
self._store.set(key, value)
def _grant(self, params: Dict[str, str], *, data: Optional[bytes] = None,
headers: Optional[Dict[str, str]] = None,
rejected: Type[AuthError] = AuthError,
rejected_statuses: tuple = (400, 401, 403)) -> EonTokenResponse:
url = self._endpoints.url(EonDefaults.PATH_OAUTH_TOKEN)
hdrs = headers if headers is not None else self._config.basic_headers()
with transport_errors(f"token ({params['grant_type']})", _PROVIDER):
try:
kwargs: Dict[str, Any] = {"params": params, "headers": hdrs, "operation": "auth"}
if data is not None:
kwargs["data"] = data
else:
kwargs["json"] = {}
resp = self._http.post(url, **kwargs)
return EonTokenResponse.from_api_response(resp.json())
except HTTPError as exc:
status = exc.response.status_code if exc.response is not None else None
if status in (400, 401, 403):
kind = rejected if status in rejected_statuses else AuthError
raise kind(f"{_PROVIDER}: {params['grant_type']} grant rejected (HTTP {status})") from exc
raise error_from_exception(exc) from exc
def _grant_password(self) -> EonTokenResponse:
device_number = self._store.get(K_DEVICE_NUMBER)
if not device_number:
raise AuthError(f"{_PROVIDER}: no device number; device registration failed")
boundary = "----EonBoundary" + secrets.token_hex(8)
username_hash = hashlib.sha256(self._credentials.username.encode("utf-8")).hexdigest().upper()
fields = (("username", username_hash),
("password", self._credentials.password),
("device_number", device_number))
body = "".join(
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'
for name, value in fields
) + f"--{boundary}--"
headers = {
"User-Agent": self._config.user_agent,
"Accept": "application/json",
"Authorization": self._config.basic_auth_value(),
"Content-Type": f"multipart/form-data; boundary={boundary}",
}
# 400/401 = wrong credentials (permanent); a 403 may be a WAF -> transient.
return self._grant({"grant_type": "password"}, data=body.encode("utf-8"), headers=headers,
rejected=EonLoginRejected, rejected_statuses=(400, 401))
def _login_otp(self) -> None:
self._ensure_device()
device_number = self._store.get(K_DEVICE_NUMBER) or ""
if self._pending_otp is None:
data = self._generic_get(EonDefaults.PATH_OTP, {"deviceNumber": device_number})
otp = data.get("otp") if isinstance(data, dict) else None
if not otp:
raise ServerError(f"{_PROVIDER}: OTP response has no code")
minutes = int(data.get("expiresInSeconds") or 0) // 60
self._pending_otp = str(otp)
raise EonOtpRequiredError(
f"{_PROVIDER}: confirm the one-time code {otp} in the operator's portal",
otp=str(otp), expires_minutes=minutes,
)
otp, self._pending_otp = self._pending_otp, None
try:
self._apply(self._grant({"grant_type": "otp", "otp": otp, "device_number": device_number}))
except AuthError as exc:
raise AuthError(f"{_PROVIDER}: the one-time code was not confirmed or has expired") from exc
# ------------------------------------------------------------------
# Client token + 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.
Returns the response; callers inspect `status_code` themselves."""
func = getattr(self._http, method, None)
if func is None:
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))
kwargs: Dict[str, Any] = {"headers": headers, "operation": "api", "raise_for_status": False}
if params:
kwargs["params"] = params
if body is not None:
kwargs["json"] = body
resp = func(url, **kwargs)
if resp.status_code != 401 or attempt == 2:
return resp
raise ServerError(f"{_PROVIDER}: unreachable") # pragma: no cover
def _generic_get(self, path: str, params: Dict[str, Any]) -> Any:
with transport_errors(path, _PROVIDER):
resp = self._generic_call("get", path, params=params)
if resp.status_code != 200:
if resp.status_code in (400, 401, 403):
raise AuthError(f"{_PROVIDER}: {path} rejected (HTTP {resp.status_code})")
try:
resp.raise_for_status()
except HTTPError as exc:
raise error_from_exception(exc) from exc
raise ServerError(f"{_PROVIDER}: {path} answered HTTP {resp.status_code}")
return resp.json()
def _ensure_serial(self) -> str:
serial = self._store.get(K_DEVICE_SERIAL)
if not serial:
serial = "".join(secrets.choice(_SERIAL_ALPHABET) for _ in range(21))
self._store.set(K_DEVICE_SERIAL, serial)
return serial
def _ensure_device(self) -> None:
if self._store.get(K_DEVICE_ID) and self._store.get(K_DEVICE_NUMBER):
return
self._register_device()
def _reset_device(self) -> None:
self.invalidate()
self._store.delete(K_DEVICE_ID)
self._store.delete(K_DEVICE_NUMBER)
self._register_device()
def _register_device(self) -> None:
"""v2 (PATCH) when a deviceNumber is already known and the HTTP layer
can PATCH; v1 (POST) mints one otherwise. A v2 answer other than
404/405/501 is final: falling back would hide the rejection."""
serial = self._ensure_serial()
platform = self._config.platform
number = self._store.get(K_DEVICE_NUMBER)
with transport_errors("device registration", _PROVIDER):
if number and hasattr(self._http, "patch"):
resp = self._generic_call("patch", EonDefaults.PATH_DEVICES_V2, body={
"deviceNumber": number,
"deviceName": platform.device_name,
"modelName": platform.device_model,
"clientSwVersion": platform.client_sw_version,
**({"clientSwBuild": platform.client_sw_build} if platform.is_android_tv else {}),
"systemSwVersion": {"systemName": platform.system_sw, "version": platform.system_version},
})
if resp.status_code in (200, 206):
self._store_device(resp)
return
if resp.status_code not in (404, 405, 501):
raise AuthError(
f"{_PROVIDER}: device registration rejected (HTTP {resp.status_code})"
)
elif number:
logger.warning(f"{_PROVIDER}: http_manager cannot PATCH; registering via v1")
body: Dict[str, Any] = {
"deviceName": platform.device_name,
"deviceType": platform.device_type,
"modelName": platform.device_model,
"platform": platform.device_platform,
"serial": serial,
"clientSwVersion": platform.client_sw_version,
}
if platform.is_android_tv:
body["clientSwBuild"] = platform.client_sw_build
body["systemSwVersion"] = {"name": platform.system_sw, "version": platform.system_version}
if platform.is_android_tv:
body["fcmToken"] = ""
resp = self._generic_call("post", EonDefaults.PATH_DEVICES_V1, body=body)
if resp.status_code not in (200, 206):
if resp.status_code in (400, 401, 403):
raise AuthError(f"{_PROVIDER}: device registration rejected (HTTP {resp.status_code})")
raise ServerError(f"{_PROVIDER}: device registration failed (HTTP {resp.status_code})")
self._store_device(resp)
def _store_device(self, resp: Any) -> None:
try:
reg = EonDeviceRegistration.from_api_response(resp.json())
except ValueError as exc:
raise ServerError(f"{_PROVIDER}: {exc}") from exc
self._store.set(K_DEVICE_ID, reg.device_id)
self._store.set(K_DEVICE_NUMBER, reg.device_number)
logger.info(f"{_PROVIDER}: device registered")
@@ -0,0 +1,226 @@
# 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
@@ -0,0 +1,78 @@
# streaming_providers/providers/eon/catchup_manager.py
"""
Eon catchup manager.
Catchup is the live URL plus a start time inside the encrypted payload
(`t=<start>000`) and the timeshift server list; it is minted per request
(fresh ctime and session id) by EonChannelManager.stream_url, which this
manager borrows as an explicit keyword-only extra (`channels=`).
* The API only takes a start time: `end_time` / `epg_id` are accepted for
signature compatibility and IGNORED.
* Per-channel window = `cutvDelay` of v4/channels (168 h in all samples),
enforced here: a start outside the window, in the future, or on a channel
without `cutvEnabled` returns None ("no catchup"), never the live URL.
* Streams are clear HLS: get_catchup_drm stays [] (the backend then reads the
manifest itself). Catchup is fetched from the CDN, so the headers are the
same CDN headers as live (no token).
"""
import time
from typing import Any, Dict, Optional
from ...base.managers import CatchupManager
from .constants import EonConfig, EonDefaults
class EonCatchupManager(CatchupManager):
def __init__(
self,
*,
http_manager: Any,
auth: Any,
country: str,
config: EonConfig,
channels: Any,
):
super().__init__(
http_manager=http_manager, auth=auth, country=country, config=config
)
self._channels = channels
@property
def catchup_window_hours(self) -> int:
# Provider-wide MAXIMUM, a constant on purpose: this property is read
# without a channel and must not trigger network I/O.
return EonDefaults.CATCHUP_WINDOW_HOURS
def catchup_window_for_channel(self, content_id: str) -> int:
record = self._channels.record(content_id)
return record.catchup_hours if record else 0
def get_catchup_manifest(
self,
content_id: str,
start_time: int,
end_time: Optional[int] = None,
epg_id: Optional[str] = None,
**kw: Any,
) -> Optional[str]:
record = self._channels.record(content_id)
if record is None or record.catchup_hours <= 0:
return None
now = int(time.time())
if start_time <= 0 or start_time > now:
return None
if now - start_time > record.catchup_hours * 3600:
return None
return self._channels.stream_url(record, start_time=int(start_time))
def get_catchup_manifest_headers(
self,
content_id: str,
start_time: int,
end_time: Optional[int] = None,
epg_id: Optional[str] = None,
**kw: Any,
) -> Dict[str, str]:
return self.config.stream_headers()
@@ -0,0 +1,177 @@
# streaming_providers/providers/eon/channel_manager.py
"""
Eon channel manager.
Live channels come from v4/channels (TV, plus RADIO when enabled); the list
order is the channel number. Manifest and DRM do not share state here: Eon
streams are clear HLS (DRM_IN_MANAGERS = False), the "manifest" is a signed,
AES-encrypted CDN URL minted locally (stream_url.py).
Return conventions (ChannelManager ABC):
get_channel_manifest -> None for an id that is not in the channel list
(the router falls through); raises EntitlementError
for a channel the account is not subscribed to.
The URL carries a server ctime the CDN accepts for ~20 s and a session id it
will not accept twice, so URLs are cached for a few seconds only (a manifest
and a header call for the same id share one URL, nothing longer).
"""
import threading
import time
import uuid
from typing import Any, Dict, List, Optional, Tuple
from ...base.errors import (
AuthError, EntitlementError, GeoBlockError, ProviderError, RateLimitError,
)
from ...base.managers import ChannelManager
from ...base.models import Channel
from ...base.utils.logger import logger
from .constants import EonConfig, EonDefaults
from .models import EonChannel
from .stream_url import StreamUrlParams, build_stream_url
_PROVIDER = EonDefaults.PROVIDER_LABEL
class EonChannelManager(ChannelManager):
def __init__(
self,
*,
http_manager: Any,
auth: Any,
country: str,
config: EonConfig,
api: Any,
endpoints: Any,
catalog: Any,
url_cache: Optional[Dict[Tuple[str, Optional[int]], Tuple[str, float]]] = None,
):
super().__init__(
http_manager=http_manager, auth=auth, country=country, config=config
)
self._api = api
self._endpoints = endpoints
self._catalog = catalog
# Provider-owned dict, borrowed by reference; cleared on credential change.
self._url_cache = url_cache if url_cache is not None else {}
self._records: Dict[str, EonChannel] = {}
self._records_lock = threading.RLock()
# ------------------------------------------------------------------
# Channels
# ------------------------------------------------------------------
def get_channels(self, **kw: Any) -> List[Channel]:
entries = self._fetch(EonDefaults.CHANNEL_TYPE_TV, is_radio=False, required=True)
if self.config.include_radio:
entries += self._fetch(EonDefaults.CHANNEL_TYPE_RADIO, is_radio=True, required=False)
if self.config.hide_unsubscribed:
entries = [e for e in entries if e.subscribed]
with self._records_lock:
self._records = {e.id: e for e in entries}
number = self.config.channel_start_number
channels: List[Channel] = []
for entry in entries:
channels.append(entry.to_channel(
EonDefaults.PROVIDER_NAME, country=self.country or "BG",
language=self.config.language, number=number,
logo_url=self._endpoints.image_url(entry.logo_path),
short_names=self.config.short_names,
))
number += 1
return channels
def _fetch(self, channel_type: str, *, is_radio: bool, required: bool) -> List[EonChannel]:
try:
raw = self._api.get_json(
EonDefaults.PATH_CHANNELS,
params={"channelType": channel_type, "channelSort": EonDefaults.CHANNEL_SORT},
)
except (AuthError, RateLimitError, GeoBlockError):
raise
except ProviderError as exc:
if required:
raise
logger.warning(f"{_PROVIDER}: {channel_type} channels unavailable: {exc}")
return []
parsed: List[EonChannel] = []
for entry in raw if isinstance(raw, list) else []:
try:
parsed.append(EonChannel.from_api_response(entry, is_radio=is_radio))
except (KeyError, TypeError, AttributeError, ValueError) as exc:
logger.warning(f"{_PROVIDER}: skipping malformed channel entry: {exc!r}")
return parsed
def record(self, content_id: str) -> Optional[EonChannel]:
"""The parsed channel for an id (loads the list on first use)."""
with self._records_lock:
known = bool(self._records)
if not known:
self.get_channels()
with self._records_lock:
return self._records.get(str(content_id))
# ------------------------------------------------------------------
# Manifest / headers
# ------------------------------------------------------------------
def get_channel_manifest(self, content_id: str, **kw: Any) -> Optional[str]:
record = self.record(content_id)
if record is None:
return None
return self.stream_url(record, start_time=None)
def get_channel_manifest_headers(self, content_id: str, **kw: Any) -> Dict[str, str]:
# CDN headers (browser / smart-TV UA), NOT the API headers.
# Only takes effect with HEADERS_FROM_MANAGERS = True.
return self.config.stream_headers()
# ------------------------------------------------------------------
# URL minting (shared with the catchup manager)
# ------------------------------------------------------------------
def stream_url(self, record: EonChannel, *, start_time: Optional[int]) -> str:
if not record.subscribed or not record.publishing_points:
raise EntitlementError(f"{_PROVIDER}: not subscribed to {record.name}")
key = (record.id, start_time)
now = time.time()
cached = self._url_cache.get(key)
if cached and (now - cached[1]) < EonDefaults.URL_CACHE_TTL:
return cached[0]
point = record.publishing_points[0]
live = start_time is None
kind = (EonDefaults.SIG_RADIO if record.is_radio
else EonDefaults.SIG_LIVE if live else EonDefaults.SIG_CUTV)
sig = point.sig_for(kind) or point.sig_for(EonDefaults.SIG_LIVE) or ""
creds = self.auth.stream_credentials()
profile = self._catalog.best_profile(point.profile_ids, radio=record.is_radio)
server = self._catalog.server(live=live)
url = build_stream_url(StreamUrlParams(
platform=self.config.platform,
publishing_point=point.name,
stream_profile=profile.core_stream_id,
service_provider=self._catalog.service_provider,
stream_user=creds.stream_user,
stream_key=creds.stream_key,
server_host=server.hostname,
server_ip=server.ip,
device_number=creds.device_number,
sig=sig,
aa_enabled=record.aa_enabled,
video_bitrate=profile.video_bitrate, # NOT the audio bitrate (AO96 -> 403.5300)
ctime_ms=self._api.server_time(),
session_id=str(uuid.uuid4()),
adaptive=self.config.adaptive_hint,
start_time=start_time,
))
self._url_cache[key] = (url, now)
return url
def clear_url_cache(self) -> None:
self._url_cache.clear()
@@ -0,0 +1,255 @@
# streaming_providers/providers/eon/constants.py
"""
Eon constants -- the SINGLE SOURCE OF TRUTH for every URL, header value,
identifier and default of this provider.
Identity rule: ONE id, used everywhere
directory name == AVAILABLE_PROVIDERS key == provider_name
== PROVIDER_NAME == credentials / session / enable-setting key
Never rename it after release.
Eon is a white-label platform (united.cloud): the same API serves several
operator brands. The brand selects the CDN/API host at runtime (see
bootstrap.EonEndpoints); the platform ("web" / "androidtv") selects the
client identity (OAuth client, device bodies, URL payload layout, UA).
Never log anything defined in PLATFORMS (client secrets) or any stream key.
"""
import base64
from dataclasses import dataclass
from typing import Dict, Optional
from ...base.errors import ConfigurationError
class EonDefaults:
PROVIDER_NAME = "eon"
PROVIDER_LABEL = "EON"
PROVIDER_LOGO = "" # TODO: no logo URL known yet
# One entry pins the registry's construction country (UPPERCASE).
# Brands are not 1:1 with countries; the default brand is Vivacom (BG).
SUPPORTED_COUNTRIES = ("BG",)
DEFAULT_BRAND = "vivacom"
DEFAULT_PLATFORM = "web"
DEFAULT_LANGUAGE = "bg"
# --- Broker (brand / CDN discovery, generic client token) ------------
BROKER_URL = "https://broker.global.united.cloud/"
GLOBAL_HOST = "global.united.cloud/"
PATH_BRANDS = "v2/brands"
PATH_CDNINFO = "v1/cdninfo"
PATH_OAUTH_TOKEN = "oauth/token"
# --- API (relative to the resolved api base) -------------------------
PATH_OTP = "v1/otp"
PATH_DEVICES_V1 = "v1/devices"
PATH_DEVICES_V2 = "v2/devices"
PATH_SERVICE_PROVIDER = "v1/sp"
PATH_PROFILES = "v1/rndprofiles"
PATH_SERVERS = "v1/servers"
PATH_TIME = "v1/time"
PATH_CHANNELS = "v4/channels"
PATH_EPG = "v2/events/epg"
CHANNEL_TYPE_TV = "TV"
CHANNEL_TYPE_RADIO = "RADIO"
CHANNEL_SORT = "POSITION"
# --- Stream URL ------------------------------------------------------
PLAYER = "m3u8"
CONN_TYPE_BROWSER = "BROWSER"
CONN_TYPE_ETHERNET = "ETHERNET"
SIG_LIVE = "live"
SIG_CUTV = "cutv"
SIG_RADIO = "radio"
# The CDN rejects an encrypted URL whose ctime is older than ~20 s.
URL_CACHE_TTL = 5.0
# Position (1-based) in /v1/servers; the addon always took the 2nd
# entry (3rd on Android TV). Positional on purpose -- see README of the
# port; log when the list is shorter.
SERVER_INDEX_WEB = 2
SERVER_INDEX_ANDROIDTV = 3
# video-bitrate floor sent as minvbr
MIN_VIDEO_BITRATE = 100
# --- Catalog cache ---------------------------------------------------
CATALOG_TTL_SECONDS = 3600
TIMEOUT = 30
# --- EPG -------------------------------------------------------------
# Rolling 7-day archive (older dates answer HTTP 400 invalid_input).
# The future horizon is NOT verified against a real response.
EPG_PAST_DAYS = 7
EPG_FUTURE_DAYS = 7
EPG_ICON_SIZE = "STB_XL"
# --- Catchup ---------------------------------------------------------
# Provider-wide maximum (the per-channel value is cutvDelay of v4/channels;
# 604800000 ms = 168 h in every sample seen).
CATCHUP_WINDOW_HOURS = 168
# brand key (config) -> CDN identifier as the broker knows it
BRAND_IDENTIFIERS: Dict[str, str] = {
"sbb": "sbb-qa",
"telemach": "telemach",
"telemach-hr": "telemach-hr",
"telemach-si": "telemach-si",
"vivacom": "vivacom",
"nova": "nova",
}
@dataclass(frozen=True)
class EonPlatform:
key: str
api_prefix: str
api_selector: str
device_type: str
device_name: str
device_model: str
device_platform: str
client_sw_version: str
client_sw_build: str
system_sw: str
system_version: str
user_agent: str
client_id: str
client_secret: str
conn_type: str
server_index: int
@property
def is_android_tv(self) -> bool:
return self.key == "androidtv"
PLATFORMS: Dict[str, EonPlatform] = {
"web": EonPlatform(
key="web",
api_prefix="web",
api_selector="be",
device_type="web_linux_chrome",
device_name="",
device_model="Chrome 150",
device_platform="web",
client_sw_version="",
client_sw_build="",
system_sw="Linux",
system_version="x86_64",
user_agent=(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
),
client_id="b8d9ade4-1093-46a7-a4f7-0e47be463c10",
client_secret="1w4dmww87x1e9l89essqvc81pidrqsa0li1rva23",
conn_type=EonDefaults.CONN_TYPE_BROWSER,
server_index=EonDefaults.SERVER_INDEX_WEB,
),
"androidtv": EonPlatform(
key="androidtv",
api_prefix="android-tv",
api_selector="af31",
device_type="Android 11",
device_name="Android TV 30",
device_model="SHIELD Android TV",
device_platform="android_tv",
client_sw_version="8.1.3",
client_sw_build="8.1.35906",
system_sw="Android",
system_version="11",
user_agent=(
"Mozilla/5.0 (Linux; Android 11; SHIELD Android TV "
"Build/RQ1A.210105.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) "
"Version/4.0 Chrome/150.0.0.0 Mobile Safari/537.36; "
"XDKAndroidWebView/3.0.1/XDKWebView NVIDIA NVIDIA/mdarcy/"
"mdarcy:11/RQ1A.210105.003/7825230_3167.5736:user/release-keys "
"NVIDIA AndroidTV 1.00A_ATV SHIELD Android TV Android/11 "
"ExoPlayer ((1.00A_ATV::1.14.1::androidtv::)"
),
client_id="5a3e24b8-70cd-4958-b716-af9ce053e594",
client_secret="aazy6orsi9elhhs17e47lfb4palgszw6igf4y26z",
conn_type=EonDefaults.CONN_TYPE_ETHERNET,
server_index=EonDefaults.SERVER_INDEX_ANDROIDTV,
),
}
class EonConfig:
"""
Per-instance configuration.
ONE instance is created by the provider and SHARED with auth, api,
bootstrap and managers. Never rebuild a second config from a subset.
"""
def __init__(self, config_dict: Optional[dict] = None):
c = config_dict or {}
self.brand = str(c.get("brand", EonDefaults.DEFAULT_BRAND)).lower()
if self.brand not in BRAND_IDENTIFIERS:
raise ConfigurationError(
f"{EonDefaults.PROVIDER_LABEL}: unknown brand {self.brand!r} "
f"(known: {', '.join(sorted(BRAND_IDENTIFIERS))})"
)
self.brand_identifier = BRAND_IDENTIFIERS[self.brand]
platform_key = str(c.get("platform", EonDefaults.DEFAULT_PLATFORM)).lower()
if platform_key not in PLATFORMS:
raise ConfigurationError(
f"{EonDefaults.PROVIDER_LABEL}: unknown platform {platform_key!r} "
f"(known: {', '.join(sorted(PLATFORMS))})"
)
self.platform = PLATFORMS[platform_key]
self.user_agent = c.get("user_agent") or self.platform.user_agent
self.timeout = c.get("timeout", EonDefaults.TIMEOUT)
self.language = c.get("language", EonDefaults.DEFAULT_LANGUAGE)
self.hide_unsubscribed = bool(c.get("hide_unsubscribed", False))
self.include_radio = bool(c.get("include_radio", True))
self.short_names = bool(c.get("short_names", False))
self.channel_start_number = int(c.get("channel_start_number", 1))
# `adaptive=true` in the URL payload. The addon sent it whenever
# inputstream.adaptive plays (live AND replay); ffmpegdirect replay
# rejects it. The backend feeds inputstream.adaptive.
self.adaptive_hint = bool(c.get("adaptive_hint", True))
self.server_index = int(c.get("server_index", self.platform.server_index))
# ----- Header builders ---------------------------------------------
def basic_auth_value(self) -> str:
raw = f"{self.platform.client_id}:{self.platform.client_secret}"
return "Basic " + base64.b64encode(raw.encode("ascii")).decode("ascii")
def basic_headers(self) -> dict:
"""Client-credentials headers (broker calls, token endpoints)."""
return {
"User-Agent": self.user_agent,
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": self.basic_auth_value(),
}
def bearer_headers(self, access_token: str) -> dict:
"""Headers for the provider's JSON API (NOT for the CDN)."""
return {
"User-Agent": self.user_agent,
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"bearer {access_token}",
}
def epg_headers(self) -> dict:
"""Extra header the EPG endpoint wants (epoch times)."""
return {"x-ucp-time-format": "timestamp"}
def stream_headers(self) -> dict:
"""
Headers for MPD / segment fetches by the player (CDN). The CDN blocks
requests without a recognised browser / smart-TV UA; it must NOT get
API headers (tokens).
"""
return {"User-Agent": self.user_agent}
@@ -0,0 +1,133 @@
# streaming_providers/providers/eon/epg_manager.py
"""
Eon EPG manager (v2/events/epg?cid=<id>&date=YYYY-MM-DD).
The endpoint is DAY based, one channel per request. Observed on a Vivacom
(Europe/Sofia, UTC+3) sample: `date=2026-10-22` starts with an event at
00:15 UTC (03:15 local), i.e. the day is a UTC day selected by programme START
time. So a programme that began before 00:00 UTC and is still running is only
in the PREVIOUS day's answer: get_epg() therefore also asks for the day before
the window and filters by overlap. (One sample; verify with a channel whose
programme crosses 00:00 UTC.)
The archive is 7 days; a date beyond it answers HTTP 400. The backend clamps
to epg_window, the oldest day additionally tolerates a 400 (day boundary).
`limit=` in **kw is ignored knowingly.
Failure semantics: a failed request RAISES (session errors on every endpoint);
a day without programmes is simply []. ISOLATE_GRID_FAILURES = True so one bad
channel cannot take the grid down.
"""
import time
from datetime import date, datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
from ...base.errors import BadRequestError
from ...base.managers import EpgManager
from ...base.models.epg_models import EPGEntry
from ...base.utils.logger import logger
from .constants import EonConfig, EonDefaults
from .models import EonEpgEvent
_PROVIDER = EonDefaults.PROVIDER_LABEL
_DAY = 86400
def _utc_date(ts: int) -> date:
return datetime.fromtimestamp(ts, timezone.utc).date()
def _to_epoch(value: Optional[datetime], default: int) -> int:
return default if value is None else int(value.timestamp())
class EonEpgManager(EpgManager):
ISOLATE_GRID_FAILURES = True
def __init__(
self,
*,
http_manager: Any,
auth: Any,
country: str,
config: EonConfig,
api: Any,
endpoints: Any,
):
super().__init__(
http_manager=http_manager, auth=auth, country=country, config=config
)
self._api = api
self._endpoints = endpoints
@property
def epg_window(self) -> Tuple[int, int]:
return EonDefaults.EPG_PAST_DAYS, EonDefaults.EPG_FUTURE_DAYS
def get_epg(
self,
channel_id: str,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
**kw: Any,
) -> List[EPGEntry]:
now = int(time.time())
past, future = self.epg_window
oldest, newest = now - past * _DAY, now + future * _DAY
start = _to_epoch(start_time, now - now % _DAY)
end = _to_epoch(end_time, start + _DAY)
start, end = max(start, oldest), min(end, newest)
if end <= start:
return []
oldest_day = _utc_date(oldest)
first_day = max(_utc_date(start) - timedelta(days=1), oldest_day)
last_day = _utc_date(end - 1)
events: Dict[str, EonEpgEvent] = {}
day = first_day
while day <= last_day:
for event in self._fetch_day(channel_id, day, tolerate_400=(day == oldest_day)):
events.setdefault(event.id, event)
day += timedelta(days=1)
entries: List[EPGEntry] = []
for event in sorted(events.values(), key=lambda e: e.start):
try:
entry = event.to_entry(
EonDefaults.PROVIDER_NAME, channel_id,
self._endpoints.image_url(event.icon_path),
)
except ValueError as exc: # empty title, end <= start, ...
logger.warning(f"{_PROVIDER}: skipping invalid EPG event {event.id}: {exc!r}")
continue
if entry.overlaps_with(start, end):
entries.append(entry)
return entries
def _fetch_day(self, channel_id: str, day: date, *, tolerate_400: bool) -> List[EonEpgEvent]:
try:
raw = self._api.get_json(
EonDefaults.PATH_EPG,
params={"cid": channel_id, "date": day.isoformat()},
extra_headers=self.config.epg_headers(),
)
except BadRequestError:
if tolerate_400:
logger.debug(f"{_PROVIDER}: EPG day {day} is outside the archive")
return []
raise
events: List[EonEpgEvent] = []
for item in raw if isinstance(raw, list) else []:
try:
event = EonEpgEvent.from_api_response(item)
except (KeyError, TypeError, AttributeError, ValueError) as exc:
logger.warning(f"{_PROVIDER}: skipping malformed EPG event: {exc!r}")
continue
if event.channel_id is not None and event.channel_id != str(channel_id):
continue
events.append(event)
return events
@@ -0,0 +1,22 @@
# streaming_providers/providers/eon/errors.py
"""Eon-specific typed errors (subclasses of base.errors)."""
from ...base.errors import AuthError
class EonLoginRejected(AuthError):
"""The password grant was refused (wrong credentials / unknown device).
PERMANENT: no retry until the credentials change (see EonAuth gate)."""
class EonOtpRequiredError(AuthError):
"""
Android TV login needs a one-time code the user enters in the operator's
web portal. NOT retryable by the backend: the settings UI shows `otp`,
the user confirms it, and the next auth attempt exchanges it.
"""
def __init__(self, message: str, *, otp: str = "", expires_minutes: int = 0):
super().__init__(message)
self.otp = otp
self.expires_minutes = expires_minutes
@@ -0,0 +1,268 @@
# streaming_providers/providers/eon/models.py
"""
Provider-local data models: plain dataclasses + from_api_response parsers.
No business logic, no network calls.
Parsing is STRICT on required fields (KeyError); the channel manager parses
entry by entry and skips malformed entries, so one bad channel cannot take
the whole list down. Optional fields tolerate null (the v4 payloads carry
`ageRating: null` and duplicate category ids).
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from ...base.models import Channel
from ...base.models.epg_models import EPGEntry, EPGFlags
from .constants import EonDefaults
def _int_or_zero(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
# ----------------------------------------------------------------------
# Channels (v4/channels)
# ----------------------------------------------------------------------
@dataclass
class EonPublishingPoint:
name: str
profile_ids: List[int]
sigs: Dict[str, str] = field(default_factory=dict) # playerCfg type -> sig
@classmethod
def from_api_response(cls, data: Dict[str, Any]) -> "EonPublishingPoint":
sigs: Dict[str, str] = {}
for cfg in data.get("playerCfgs") or []:
if isinstance(cfg, dict) and cfg.get("type") and cfg.get("sig"):
sigs[str(cfg["type"])] = str(cfg["sig"])
return cls(
name=data["publishingPoint"],
profile_ids=[_int_or_zero(i) for i in data.get("profileIds") or []],
sigs=sigs,
)
def sig_for(self, kind: str) -> Optional[str]:
return self.sigs.get(kind)
@dataclass
class EonChannel:
id: str
name: str
short_name: str
subscribed: bool
cutv_enabled: bool
cutv_delay_ms: int
aa_enabled: bool
is_radio: bool
publishing_points: List[EonPublishingPoint]
logo_path: Optional[str] = None
@classmethod
def from_api_response(cls, data: Dict[str, Any], *, is_radio: bool = False) -> "EonChannel":
return cls(
id=str(data["id"]),
name=data["name"],
short_name=data.get("shortName") or data["name"],
subscribed=bool(data.get("subscribed")),
cutv_enabled=bool(data.get("cutvEnabled")),
cutv_delay_ms=_int_or_zero(data.get("cutvDelay")),
aa_enabled=bool(data.get("aaEnabled")),
is_radio=is_radio,
publishing_points=[
EonPublishingPoint.from_api_response(p)
for p in data.get("publishingPoint") or []
],
logo_path=_pick_logo(data.get("images")),
)
@property
def catchup_hours(self) -> int:
if not self.cutv_enabled or self.cutv_delay_ms <= 0:
return 0
return self.cutv_delay_ms // 3_600_000
def to_channel(
self, provider_name: str, *, country: str, language: str,
number: int, logo_url: Optional[str], short_names: bool,
) -> Channel:
name = self.short_name if short_names else self.name
# NOTE the factory parameter names: create_live_channel and
# create_radio_channel take `channel_id`.
factory = Channel.create_radio_channel if self.is_radio else Channel.create_live_channel
channel = factory(name=name, channel_id=self.id, provider=provider_name)
channel.logo_url = logo_url or ""
channel.channel_number = number
channel.catchup_hours = self.catchup_hours
channel.country = country.upper()
channel.language = language
channel.streaming_format = "hls"
return channel
def _pick_logo(images: Any) -> Optional[str]:
"""Largest LOGO_16_9_DARK, else the largest of any logo type."""
if not isinstance(images, dict):
return None
for key in ("LOGO_16_9_DARK", *sorted(images)):
variants = images.get(key)
if isinstance(variants, list) and variants:
best = max(
(v for v in variants if isinstance(v, dict) and v.get("path")),
key=lambda v: _int_or_zero(v.get("height")),
default=None,
)
if best:
return best["path"]
return None
# ----------------------------------------------------------------------
# Catalog (v1/rndprofiles, v1/servers, v1/sp)
# ----------------------------------------------------------------------
@dataclass
class EonRenderingProfile:
id: int
name: str
core_stream_id: str
audio_bitrate: int
video_bitrate: int
@classmethod
def from_api_response(cls, data: Dict[str, Any]) -> "EonRenderingProfile":
return cls(
id=int(data["id"]),
name=data.get("name") or "",
core_stream_id=data["coreStreamId"],
audio_bitrate=_int_or_zero(data.get("audioBitrate")),
video_bitrate=_int_or_zero(data.get("videoBitrate")),
)
@dataclass
class EonServer:
id: str
ip: str
hostname: str
@classmethod
def from_api_response(cls, data: Dict[str, Any]) -> "EonServer":
server = cls(
id=str(data.get("id") or ""),
ip=data.get("ip") or "",
hostname=data["hostname"],
)
return server
# ----------------------------------------------------------------------
# Auth
# ----------------------------------------------------------------------
@dataclass
class EonTokenResponse:
"""Response of oauth/token (any grant)."""
access_token: str
refresh_token: str = ""
stream_key: str = ""
stream_user: str = ""
device_number: str = ""
expires_in: Optional[float] = None
@classmethod
def from_api_response(cls, data: Dict[str, Any]) -> "EonTokenResponse":
if not isinstance(data, dict) or not data.get("access_token"):
raise ValueError("token response is missing access_token")
expires = data.get("expires_in")
return cls(
access_token=data["access_token"],
refresh_token=data.get("refresh_token") or "",
stream_key=data.get("stream_key") or "",
stream_user=data.get("stream_un") or "",
device_number=data.get("device_number") or "",
expires_in=float(expires) if isinstance(expires, (int, float)) else None,
)
@dataclass
class EonDeviceRegistration:
device_id: str
device_number: str
@classmethod
def from_api_response(cls, data: Dict[str, Any]) -> "EonDeviceRegistration":
if not isinstance(data, dict):
raise ValueError("device response is not an object")
device_id = _int_or_zero(data.get("deviceId"))
number = data.get("deviceNumber") or ""
if not device_id or not number:
raise ValueError("device response is missing deviceId / deviceNumber")
return cls(device_id=str(device_id), device_number=number)
# ----------------------------------------------------------------------
# EPG (v2/events/epg)
# ----------------------------------------------------------------------
@dataclass
class EonEpgEvent:
id: str
channel_id: Optional[str]
title: str
original_title: Optional[str]
description: Optional[str]
start: int # Unix seconds (the API sends ms)
end: int
age_rating: int
live: bool
season_number: int
episode_number: int
icon_path: Optional[str]
@classmethod
def from_api_response(cls, data: Dict[str, Any]) -> "EonEpgEvent":
icon = None
for image in data.get("images") or []:
if isinstance(image, dict) and image.get("size") == EonDefaults.EPG_ICON_SIZE and image.get("path"):
icon = image["path"]
break
channel_id = data.get("channelId")
return cls(
id=str(data["id"]),
channel_id=str(channel_id) if channel_id is not None else None,
title=data["title"],
original_title=data.get("originalTitle"),
description=data.get("shortDescription"),
start=int(data["startTime"]) // 1000,
end=int(data["endTime"]) // 1000,
age_rating=_int_or_zero(data.get("ageRating")), # "6" (string) or null
live=bool(data.get("liveBroadcast")),
season_number=_int_or_zero(data.get("seasonNumber")),
episode_number=_int_or_zero(data.get("episodeNumber")),
icon_path=icon,
)
def to_entry(self, provider_name: str, channel_id: str, icon_url: Optional[str]) -> EPGEntry:
flags = EPGFlags.UNDEFINED
if self.live:
flags |= EPGFlags.IS_LIVE
if self.episode_number:
flags |= EPGFlags.IS_SERIES
return EPGEntry(
broadcast_id=EPGEntry.encode_broadcast_id(provider_name, channel_id, self.start),
title=self.title,
start=self.start,
end=self.end,
# One id per broadcast -> program_id; epg_event_id stays None.
program_id=self.id,
description=self.description or None,
original_title=self.original_title if self.original_title and self.original_title != self.title else None,
icon=icon_url,
season_number=self.season_number or None,
episode_number=self.episode_number or None,
parental_rating=self.age_rating or None,
flags=flags,
)
@@ -0,0 +1,138 @@
# 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.
Eon is a white-label platform: `config["brand"]` (vivacom, nova, sbb,
telemach, telemach-hr, telemach-si) picks the operator, `config["platform"]`
("web" / "androidtv") the client identity. Geo-unblocking users also need
Kodi's global proxy: the CDN requests are made by inputstream.adaptive, which
does not use the backend's ProxyConfig.
"""
from typing import ClassVar, Dict, List, Optional
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 .catchup_manager import EonCatchupManager
from .channel_manager import EonChannelManager
from .constants import EonConfig, EonDefaults
from .epg_manager import EonEpgManager
__all__ = ["EonProvider"]
class EonProvider(ManagedProvider):
PROVIDER_LABEL: ClassVar[str] = EonDefaults.PROVIDER_LABEL
PROVIDER_LOGO: ClassVar[str] = EonDefaults.PROVIDER_LOGO
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["user_credentials"]
SUPPORTED_COUNTRIES: ClassVar[List[str]] = list(EonDefaults.SUPPORTED_COUNTRIES)
# Eon streams are clear HLS: no DRM anywhere.
DRM_IN_MANAGERS: ClassVar[bool] = False
# CDN requests need the browser / smart-TV UA the channel manager returns.
HEADERS_FROM_MANAGERS: ClassVar[bool] = True
# The backend's clear-HLS check fetches the first variant playlist of the
# master. The addon resolves variant URIs WITHOUT inheriting the master's
# query (it carries the encrypted payload), and plays fine, so variant URIs
# are self-sufficient: do not append the master query to them.
inherit_manifest_query_on_segments: ClassVar[bool] = False
@property
def provider_name(self) -> str:
return EonDefaults.PROVIDER_NAME
def __init__(
self,
country: str = "BG",
config: Optional[Dict] = None,
proxy_config: Optional[ProxyConfig] = None,
store: Optional[EonStore] = None,
):
super().__init__(country=country)
self.country = self.country.lower()
# 1. ONE config object, shared by every layer.
self.provider_config = EonConfig(config or {})
# 2. HTTP manager.
self.http_manager = self._setup_http_manager(
provider_name=EonDefaults.PROVIDER_NAME,
proxy_config=proxy_config,
user_agent=self.provider_config.user_agent,
timeout=self.provider_config.timeout,
)
# 3. Lazy collaborators: no network in __init__.
self.endpoints = EonEndpoints(
http_manager=self.http_manager, config=self.provider_config
)
self.auth = EonAuth(
http_manager=self.http_manager, config=self.provider_config,
endpoints=self.endpoints, store=store,
)
self.api = EonApi(
http_manager=self.http_manager, auth=self.auth,
endpoints=self.endpoints, config=self.provider_config,
)
self.catalog = EonCatalog(api=self.api, config=self.provider_config)
# 4. Provider-owned caches, borrowed by managers by reference.
self._url_cache: Dict = {}
# 5. Managers. NOTE: self.channels is now the ChannelManager.
self._init_managers()
def _build_channels(self):
return EonChannelManager(
http_manager=self.http_manager,
auth=self.auth,
country=self.country,
config=self.provider_config,
api=self.api,
endpoints=self.endpoints,
catalog=self.catalog,
url_cache=self._url_cache,
)
def _build_epg(self):
return EonEpgManager(
http_manager=self.http_manager,
auth=self.auth,
country=self.country,
config=self.provider_config,
api=self.api,
endpoints=self.endpoints,
)
def _build_catchup(self):
# Needs the channel manager (it mints the signed URL): built after it.
return EonCatchupManager(
http_manager=self.http_manager,
auth=self.auth,
country=self.country,
config=self.provider_config,
channels=self.channels,
)
# ------------------------------------------------------------------
# Credentials API for the settings UI
# ------------------------------------------------------------------
def set_user_credentials(self, username: str, password: str) -> bool:
"""Store credentials and log in once; True on success."""
from types import SimpleNamespace
self.auth.set_credentials(SimpleNamespace(username=username, password=password))
self._url_cache.clear()
self.catalog.invalidate()
try:
self.auth.get_access_token()
except Exception:
return False
return True
@@ -0,0 +1,153 @@
# streaming_providers/providers/eon/stream_url.py
"""
Builds the signed Eon CDN URL. Pure functions: no network, no state.
The CDN takes the whole request as an AES-CBC encrypted `;`-separated
payload (query parameter `a`, IV in `i`), both urlsafe-base64 without
padding. The key is the account's `stream_key` (urlsafe base64). The payload
carries a `ctime` (server time in ms) that the CDN accepts for ~20 s only,
and a `session` id; reusing a session id while the CDN still considers it
active gets the request rejected -- always pass a fresh one.
Layout differences (ported from PVREon.cpp / StreamRedirectProxy.cpp):
web : channel;stream;sp;u;ss;minvbr;sig;session;m;device;ctime;
conn;[adaptive];player;[t=<start>000];aa
(no maxvbr, `sig` also in the query string)
androidtv : channel;stream;sp;u;m;device;ctime;lang;player;aa;conn;
minvbr;ss;session;maxvbr[;t=<start>000;]
(no `sig`, `lang` also in the query string)
`ss` is the stream key (the same urlsafe string that is the AES key), `m` the
server IP; the URL host is the server HOSTNAME.
Live has no `t`; catchup passes the programme start (Unix seconds).
The decoded key length decides the AES variant here (16/24/32 bytes). The
C++ addon used tiny-AES whose default build is AES-128 (first 16 bytes of
the key). Verify on a device that the stream key is exactly 16 bytes; if it
is longer the two implementations disagree.
"""
import base64
import os
from dataclasses import dataclass
from typing import Callable, Optional
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from .constants import EonDefaults, EonPlatform
@dataclass(frozen=True)
class StreamUrlParams:
platform: EonPlatform
publishing_point: str
stream_profile: str # coreStreamId of the chosen rendering profile
service_provider: str
stream_user: str
stream_key: str
server_host: str # hostname: the URL host
server_ip: str # payload `m=`
device_number: str
sig: str
aa_enabled: bool
video_bitrate: int # 0 for audio-only profiles
ctime_ms: str # /v1/time -> "time", passed through verbatim
session_id: str
adaptive: bool = True
start_time: Optional[int] = None # Unix seconds; None = live
def _b64u_encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _b64u_decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def _encrypt(key: bytes, plaintext: bytes, iv: bytes) -> bytes:
if len(key) not in (16, 24, 32):
raise ValueError(f"unsupported stream key length: {len(key)} bytes")
padder = padding.PKCS7(128).padder()
padded = padder.update(plaintext) + padder.finalize()
encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
return encryptor.update(padded) + encryptor.finalize()
def build_payload(p: StreamUrlParams) -> str:
"""The plaintext that gets encrypted (exposed for tests)."""
minvbr = min(EonDefaults.MIN_VIDEO_BITRATE, p.video_bitrate)
aa = "true" if p.aa_enabled else "false"
t = f"t={p.start_time}000" if p.start_time else None
if p.platform.is_android_tv:
parts = [
f"channel={p.publishing_point}",
f"stream={p.stream_profile}",
f"sp={p.service_provider}",
f"u={p.stream_user}",
f"m={p.server_ip}",
f"device={p.device_number}",
f"ctime={p.ctime_ms}",
"lang=eng",
f"player={EonDefaults.PLAYER}",
f"aa={aa}",
f"conn={p.platform.conn_type}",
f"minvbr={minvbr}",
f"ss={p.stream_key}",
f"session={p.session_id}",
f"maxvbr={p.video_bitrate}",
]
payload = ";".join(parts)
if t:
payload += f";{t};"
return payload
parts = [
f"channel={p.publishing_point}",
f"stream={p.stream_profile}",
f"sp={p.service_provider}",
f"u={p.stream_user}",
f"ss={p.stream_key}",
f"minvbr={minvbr}",
f"sig={p.sig}",
f"session={p.session_id}",
f"m={p.server_ip}",
f"device={p.device_number}",
f"ctime={p.ctime_ms}",
f"conn={p.platform.conn_type}",
]
if p.adaptive:
parts.append("adaptive=true")
parts.append(f"player={EonDefaults.PLAYER}")
if t:
parts.append(t)
parts.append(f"aa={aa}")
return ";".join(parts)
def build_stream_url(
p: StreamUrlParams, *, iv: Optional[bytes] = None
) -> str:
"""Encrypt the payload and assemble the CDN URL. `iv` is for tests."""
iv = iv if iv is not None else os.urandom(16)
key = _b64u_decode(p.stream_key)
cipher = _encrypt(key, build_payload(p).encode("utf-8"), iv)
query = f"i={_b64u_encode(iv)}&a={_b64u_encode(cipher)}"
if p.platform.is_android_tv:
query += "&lang=eng"
query += f"&sp={p.service_provider}&u={p.stream_user}&player={EonDefaults.PLAYER}"
query += f"&session={p.session_id}"
if not p.platform.is_android_tv:
query += f"&sig={p.sig}"
return f"https://{p.server_host}/stream?{query}"
def decrypt_for_test(url_query_a: str, url_query_i: str, stream_key: str) -> str:
"""Inverse of the encryption (tests only)."""
key = _b64u_decode(stream_key)
dec = Cipher(algorithms.AES(key), modes.CBC(_b64u_decode(url_query_i))).decryptor()
padded = dec.update(_b64u_decode(url_query_a)) + dec.finalize()
unpadder = padding.PKCS7(128).unpadder()
return (unpadder.update(padded) + unpadder.finalize()).decode("utf-8")