fix provider eon

This commit is contained in:
Nirvana
2026-10-20 07:21:18 +02:00
parent bbee42e89b
commit 91e153173d
6 changed files with 205 additions and 33 deletions
@@ -9,6 +9,11 @@ from ..utils.vfs import VFS
from .base_auth import BaseAuthToken
# Any session key whose NAME contains one of these is masked in the INFO logs of
# save_session / load_session / save_scoped_token (unknown keys are logged in clear).
_SENSITIVE_KEY_PARTS = ("token", "secret", "password", "jwt", "serial", "device_number")
class SessionManager:
"""
Manages persistent session data including tokens and device IDs
@@ -667,7 +672,7 @@ class SessionManager:
"password",
"persona_token",
"persona_jwt",
]: # ← ADD THESE TWO FIELDS
] or any(part in str(key).lower() for part in _SENSITIVE_KEY_PARTS):
safe_data[key] = f"<present>" if value else f"<missing>"
elif isinstance(value, dict):
# Recursively handle nested dicts (for scoped tokens)
@@ -727,4 +732,4 @@ class SessionManager:
logger.info(f"=== END SESSION FILE DEBUG ===")
except Exception as e:
logger.error(f"Error during session file debug: {e}")
logger.error(f"Error during session file debug: {e}")
@@ -275,6 +275,13 @@ class HTTPManager:
"""Perform DELETE request with proxy support"""
return self._make_request("DELETE", url, operation, **kwargs)
def patch(self, url: str, operation: str = "api", **kwargs) -> requests.Response:
"""Perform PATCH request with proxy support.
Not retried on 429 (PATCH is not idempotent, see _IDEMPOTENT_METHODS).
Takes the same kwargs as put(), including `raise_for_status=False`."""
return self._make_request("PATCH", url, operation, **kwargs)
# ------------------------------------------------------------------
# Core request logic
# ------------------------------------------------------------------
@@ -988,9 +988,26 @@ apart in one auth class instead of one `token` field:
**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).
* **Persisted state is behind a small `Store` protocol** (`get / set / set_many / delete`)
with a non-durable `MemoryStore` default that is loudly documented as such: without a
durable store every restart registers a new device (§12.1b). The durable one is
`persistence.SessionStore` on top of `SessionManager` (one dict at
`session.json[<provider>][<country>]`, country lower-case; `save_session` REPLACES that
dict, so the store keeps a write-through cache and writes the whole thing, batched with
`set_many`, skipped when nothing changed). `default_persistence()` finds the
`SessionManager` and the credentials loader the way `BaseAuthenticator` does (injected
settings manager, else its own) and degrades to `MemoryStore` with a warning, never to
an exception. Credentials are loaded **lazily at the first login**
(`settings_manager.get_provider_credentials(provider, country)`), not in `__init__`.
* **Persist only what cannot be re-derived.** Eon stores device serial / id / number and the
refresh token. The stream key and stream user (the AES key of the CDN URL) come back with
every token response, including the refresh grant, so they stay in memory.
`SessionManager` logs the **values of keys it does not know at INFO** on every
`save_session` / `load_session`; only its mask list (`access_token`, `refresh_token`,
`client_secret`, `password`, `persona_*`) is hidden. `session_manager_patch.diff` masks by
key-name fragment (`token`, `secret`, `password`, `jwt`, `serial`, `device_number`).
Test both: a restart reuses the device and the refresh token (no password grant, no new
device), and the stream key never appears in captured log output.
* 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)
@@ -1767,6 +1784,8 @@ Things that already cost time. Each is covered by a test or the contract unless
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).
80. Persisting a re-derivable secret (stream key, anything the next token response
returns) through `SessionManager`: unknown keys are logged in clear at INFO (§9).
---
@@ -1802,9 +1821,11 @@ Known gaps in the base, so you don't hunt for them:
`_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.
* **No ready-made `Store` for Variant A providers**: `SessionManager` offers
`load_session` / `save_session` (a dict per provider + country) and scoped tokens (which
insist on `access_token` and expiry fields), not a key/value store, and it logs unknown
values in clear. Eon's `persistence.SessionStore` is the adapter; moving it (and the
masking patch) into `base/auth` would remove the 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`).
@@ -1994,6 +2015,7 @@ meet one of these:
| 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 |
| durable device identity + credentials from the settings manager | `persistence.py` | §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 |
+45 -21
View File
@@ -34,6 +34,12 @@ login is recorded ONCE, after the whole ladder has run --
signal (stale device), not a verdict, and is not recorded.
`last_error` keeps the original exception for the auth-status UI.
Persistence: only what cannot be re-derived is stored -- device serial / id /
number (client identity) and the refresh token. The STREAM KEY and stream user
come back with every token response (also the refresh grant) and live in
memory only: they are the AES key of the CDN URL, and SessionManager logs the
values of unknown keys in clear at INFO.
Rules: lazy (no network in __init__), one login for concurrent callers,
typed errors only, nothing secret in logs (lengths at most).
"""
@@ -63,8 +69,6 @@ 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
@@ -77,6 +81,7 @@ class EonStore(Protocol):
def get(self, key: str) -> Optional[str]: ...
def set(self, key: str, value: str) -> None: ...
def set_many(self, values: Dict[str, str]) -> None: ...
def delete(self, key: str) -> None: ...
@@ -92,6 +97,9 @@ class MemoryStore:
def set(self, key: str, value: str) -> None:
self._data[key] = value
def set_many(self, values: Dict[str, str]) -> None:
self._data.update(values)
def delete(self, key: str) -> None:
self._data.pop(key, None)
@@ -112,6 +120,7 @@ class EonAuth:
endpoints: EonEndpoints,
store: Optional[EonStore] = None,
credentials: Any = None,
credentials_loader: Optional[Callable[[], Any]] = None,
clock: Callable[[], float] = time.monotonic,
):
self._http = http_manager
@@ -119,6 +128,9 @@ class EonAuth:
self._endpoints = endpoints
self._store: EonStore = store if store is not None else MemoryStore()
self._credentials = credentials # object with .username / .password
self._credentials_loader = credentials_loader # lazy: no disk/network in __init__
self._stream_key: Optional[str] = None # memory only (see docstring)
self._stream_user: Optional[str] = None
self._access_token: Optional[str] = None
self._expires_at: Optional[float] = None
self._generic_token: Optional[str] = None
@@ -134,9 +146,17 @@ class EonAuth:
# ------------------------------------------------------------------
# Credentials
# ------------------------------------------------------------------
def _current_credentials(self) -> Any:
if self._credentials is None and self._credentials_loader is not None:
try:
self._credentials = self._credentials_loader()
except Exception as exc: # a broken settings backend is "no credentials"
logger.warning(f"{_PROVIDER}: could not load stored credentials: {exc!r}")
return self._credentials
@property
def has_credentials(self) -> bool:
c = self._credentials
c = self._current_credentials()
return bool(c and getattr(c, "username", None) and getattr(c, "password", None))
def set_credentials(self, credentials: Any) -> None:
@@ -186,8 +206,9 @@ class EonAuth:
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)
self._stream_key = None
self._stream_user = None
self._store.delete(K_REFRESH_TOKEN)
# ------------------------------------------------------------------
# Eon-specific
@@ -195,12 +216,12 @@ class EonAuth:
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):
if not (self._stream_key and self._stream_user and number):
raise ServerError(f"{_PROVIDER}: the session carries no stream credentials")
return StreamCredentials(stream_key=key, stream_user=user, device_number=number)
return StreamCredentials(
stream_key=self._stream_key, stream_user=self._stream_user, device_number=number
)
def recover(self, failed_token: Optional[str]) -> None:
"""Repair the session after a 401 with `failed_token`."""
@@ -285,14 +306,17 @@ class EonAuth:
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)
if tok.stream_key:
self._stream_key = tok.stream_key
if tok.stream_user:
self._stream_user = tok.stream_user
durable = {}
if tok.refresh_token:
durable[K_REFRESH_TOKEN] = tok.refresh_token
if tok.device_number:
durable[K_DEVICE_NUMBER] = tok.device_number
if durable:
self._store.set_many(durable)
def _grant(self, params: Dict[str, str], *, data: Optional[bytes] = None,
headers: Optional[Dict[str, str]] = None,
@@ -321,9 +345,10 @@ class EonAuth:
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()
creds = self._current_credentials()
username_hash = hashlib.sha256(creds.username.encode("utf-8")).hexdigest().upper()
fields = (("username", username_hash),
("password", self._credentials.password),
("password", creds.password),
("device_number", device_number))
body = "".join(
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'
@@ -487,6 +512,5 @@ class EonAuth:
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)
self._store.set_many({K_DEVICE_ID: reg.device_id, K_DEVICE_NUMBER: reg.device_number})
logger.info(f"{_PROVIDER}: device registered")
@@ -0,0 +1,102 @@
# streaming_providers/providers/eon/persistence.py
"""
Glue between EonAuth and the framework's storage managers.
SessionStore -- EonStore on top of SessionManager: one dict at
session.json[<provider>][<country>] (country LOWER-case, as
the managers key by it). Read once, written through.
default_persistence() -- finds a SessionManager and a credentials loader the
way BaseAuthenticator does (injected settings manager, else
a SettingsManager of its own); anything missing degrades to
the non-durable MemoryStore / no stored credentials, with a
warning, never to an exception.
SessionManager logs every value of keys it does not know at INFO
(`Saving session data ...`): never hand this store a secret that can be
re-derived. EonAuth persists device identity and the refresh token only (the
refresh token is on SessionManager's mask list).
"""
import threading
from typing import Any, Callable, Dict, Optional, Tuple
from ...base.utils.logger import logger
from .auth import EonStore, MemoryStore
class SessionStore:
def __init__(self, session_manager: Any, provider: str, country: Optional[str]):
self._sm = session_manager
self._provider = provider
self._country = country or None
self._lock = threading.RLock()
self._cache: Optional[Dict[str, str]] = None
def _data(self) -> Dict[str, str]:
if self._cache is None:
loaded = self._sm.load_session(self._provider, self._country)
self._cache = {k: v for k, v in (loaded or {}).items() if isinstance(v, str)}
return self._cache
def get(self, key: str) -> Optional[str]:
with self._lock:
return self._data().get(key)
def set(self, key: str, value: str) -> None:
self.set_many({key: value})
def set_many(self, values: Dict[str, str]) -> None:
with self._lock:
data = self._data()
if all(data.get(k) == v for k, v in values.items()):
return
data.update(values)
self._save()
def delete(self, key: str) -> None:
with self._lock:
data = self._data()
if key in data:
del data[key]
self._save()
def _save(self) -> None:
# save_session REPLACES the dict at that path: write the whole cache.
if not self._sm.save_session(self._provider, dict(self._data()), self._country):
logger.warning(f"{self._provider}: could not persist the session")
def default_persistence(
*, provider: str, country: str, settings_manager: Any = None,
) -> Tuple[EonStore, Optional[Callable[[], Any]]]:
"""(store, credentials_loader); never raises."""
manager = settings_manager
if manager is None:
try:
from ...base.settings.settings_manager import SettingsManager
manager = SettingsManager(config_dir=None, enable_kodi_integration=True)
except Exception as exc:
logger.warning(f"{provider}: no settings manager ({exc!r})")
if manager is not None and hasattr(manager, "register_provider"):
try:
manager.register_provider(provider)
except Exception as exc:
logger.warning(f"{provider}: could not register with the settings manager: {exc!r}")
session_manager = getattr(manager, "session_manager", None)
if session_manager is None:
try:
from ...base.auth.session_manager import SessionManager
session_manager = SessionManager()
except Exception as exc:
logger.warning(f"{provider}: no SessionManager ({exc!r}); device identity is NOT persisted")
store: EonStore = (
SessionStore(session_manager, provider, country) if session_manager is not None
else MemoryStore()
)
loader: Optional[Callable[[], Any]] = None
if manager is not None and hasattr(manager, "get_provider_credentials"):
loader = lambda: manager.get_provider_credentials(provider, country) # noqa: E731
return store, loader
@@ -24,6 +24,7 @@ from .catchup_manager import EonCatchupManager
from .channel_manager import EonChannelManager
from .constants import EonConfig, EonDefaults
from .epg_manager import EonEpgManager
from .persistence import default_persistence
__all__ = ["EonProvider"]
@@ -54,6 +55,7 @@ class EonProvider(ManagedProvider):
config: Optional[Dict] = None,
proxy_config: Optional[ProxyConfig] = None,
store: Optional[EonStore] = None,
settings_manager=None,
):
super().__init__(country=country)
self.country = self.country.lower()
@@ -69,13 +71,23 @@ class EonProvider(ManagedProvider):
timeout=self.provider_config.timeout,
)
# 3. Lazy collaborators: no network in __init__.
# 3. Persistence: device identity + refresh token via SessionManager,
# credentials via the settings manager (loaded lazily at first login).
# `store` overrides it (tests).
default_store, credentials_loader = default_persistence(
provider=EonDefaults.PROVIDER_NAME, country=self.country,
settings_manager=settings_manager,
)
# 4. 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,
endpoints=self.endpoints,
store=store if store is not None else default_store,
credentials_loader=credentials_loader,
)
self.api = EonApi(
http_manager=self.http_manager, auth=self.auth,
@@ -83,10 +95,10 @@ class EonProvider(ManagedProvider):
)
self.catalog = EonCatalog(api=self.api, config=self.provider_config)
# 4. Provider-owned caches, borrowed by managers by reference.
# 5. Provider-owned caches, borrowed by managers by reference.
self._url_cache: Dict = {}
# 5. Managers. NOTE: self.channels is now the ChannelManager.
# 6. Managers. NOTE: self.channels is now the ChannelManager.
self._init_managers()
def _build_channels(self):