diff --git a/lib/streaming_providers/providers/eon/auth.py b/lib/streaming_providers/providers/eon/auth.py index 9f25b83..d16ccd8 100644 --- a/lib/streaming_providers/providers/eon/auth.py +++ b/lib/streaming_providers/providers/eon/auth.py @@ -372,7 +372,21 @@ class EonAuth: def _grant_refresh(self, refresh: str) -> EonTokenResponse: if self._oidc is None: - return self._grant({"grant_type": "refresh_token", "refresh_token": refresh}) + # Browser capture: parameters in the query, EMPTY multipart body + # (just the closing boundary -- 44 bytes with a WebKit-style boundary). + boundary = "----WebKitFormBoundary" + secrets.token_hex(8) + body = f"--{boundary}--\r\n".encode("ascii") + headers = { + "User-Agent": self._config.user_agent, + "Accept": "application/json, text/plain, */*", + "Authorization": self._config.basic_auth_value(), + "Content-Type": f"multipart/form-data; boundary={boundary}", + **self._config.origin_headers(), + } + return self._grant( + {"grant_type": "refresh_token", "refresh_token": refresh}, + data=body, headers=headers, + ) client = self._config.oidc_client # UNVERIFIED against a browser capture: the standard refresh (same Basic auth as the code exchange) # request. A rejection just falls through to a full login (see _login). @@ -448,6 +462,7 @@ class EonAuth: "Accept": "application/json", "Authorization": self._config.basic_auth_value(), "Content-Type": f"multipart/form-data; boundary={boundary}", + **self._config.origin_headers(), } # 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, diff --git a/lib/streaming_providers/providers/eon/constants.py b/lib/streaming_providers/providers/eon/constants.py index 2d9481d..e341fb0 100644 --- a/lib/streaming_providers/providers/eon/constants.py +++ b/lib/streaming_providers/providers/eon/constants.py @@ -102,15 +102,35 @@ class EonDefaults: # 604800000 ms = 168 h in every sample seen). CATCHUP_WINDOW_HOURS = 168 + # --- Browser origin (web platform only) ------------------------------ + # The web client sends these on its token calls; an Origin/Referer check + # on the token endpoint would explain a 403 otherwise. + WEB_ORIGIN = "https://eon.tv" + WEB_REFERER = "https://eon.tv/" -# brand key (config) -> CDN identifier as the broker knows it + +# brand key (config) -> broker identifier (v2/brands) BRAND_IDENTIFIERS: Dict[str, str] = { "sbb": "sbb-qa", - "telemach": "telemach", + # Legacy alias: existing configs may still carry brand: "telemach". + "telemach": "telemach-ba", + "telemach-ba": "telemach-ba", + "telemach-me": "telemach-me", "telemach-hr": "telemach-hr", "telemach-si": "telemach-si", - "vivacom": "vivacom", "nova": "nova", + "vivacom": "vivacom", +} + +# Used when config has no explicit "brand". Keys are lower-case country codes. +COUNTRY_DEFAULT_BRANDS: Dict[str, str] = { + "ba": "telemach-ba", + "me": "telemach-me", + "hr": "telemach-hr", + "si": "telemach-si", + "rs": "sbb", + "gr": "nova", + "bg": "vivacom", } @@ -235,10 +255,15 @@ class EonConfig: bootstrap and managers. Never rebuild a second config from a subset. """ - def __init__(self, config_dict: Optional[dict] = None): + def __init__(self, config_dict: Optional[dict] = None, country: Optional[str] = None): c = config_dict or {} - self.brand = str(c.get("brand", EonDefaults.DEFAULT_BRAND)).lower() + # Brand: explicit config wins, else the country's default brand, + # else the global default. + default_brand = COUNTRY_DEFAULT_BRANDS.get( + (country or "").lower(), EonDefaults.DEFAULT_BRAND + ) + self.brand = str(c.get("brand", default_brand)).lower() if self.brand not in BRAND_IDENTIFIERS: raise ConfigurationError( f"{EonDefaults.PROVIDER_LABEL}: unknown brand {self.brand!r} " @@ -325,4 +350,10 @@ class EonConfig: requests without a recognised browser / smart-TV UA; it must NOT get API headers (tokens). """ - return {"User-Agent": self.user_agent} \ No newline at end of file + return {"User-Agent": self.user_agent} + + def origin_headers(self) -> dict: + """Browser origin the web client sends on token calls (web platform only).""" + if self.platform.is_android_tv: + return {} + return {"Origin": EonDefaults.WEB_ORIGIN, "Referer": EonDefaults.WEB_REFERER} \ No newline at end of file diff --git a/lib/streaming_providers/providers/eon/provider.py b/lib/streaming_providers/providers/eon/provider.py index b511010..cd663b8 100644 --- a/lib/streaming_providers/providers/eon/provider.py +++ b/lib/streaming_providers/providers/eon/provider.py @@ -1,5 +1,4 @@ # streaming_providers/providers/eon/provider.py -# streaming_providers/providers/eon/provider.py """ Eon provider -- orchestrator only. @@ -8,10 +7,11 @@ 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. +telemach-ba, telemach-me, telemach-hr, telemach-si) picks the operator, +`config["platform"]` ("web" / "androidtv") the client identity. When no +brand is configured, the construction country picks the default brand. +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 @@ -61,8 +61,9 @@ class EonProvider(ManagedProvider): super().__init__(country=country) self.country = self.country.lower() - # 1. ONE config object, shared by every layer. - self.provider_config = EonConfig(config or {}) + # 1. ONE config object, shared by every layer. The country selects the + # default brand when config has no explicit "brand". + self.provider_config = EonConfig(config or {}, country=self.country) # 2. HTTP manager. self.http_manager = self._setup_http_manager(