mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-20 23:02:44 +02:00
fix provider eon
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user