From a2c39ee93dd52780ea9c3a65ba9f74786aba97d1 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Fri, 13 Mar 2026 20:28:25 +0100 Subject: [PATCH] Add discovery --- .../providers/magenta2/vod_manager.py | 111 ++++++++++++------ 1 file changed, 75 insertions(+), 36 deletions(-) diff --git a/lib/streaming_providers/providers/magenta2/vod_manager.py b/lib/streaming_providers/providers/magenta2/vod_manager.py index 04d08d2..c9e2499 100644 --- a/lib/streaming_providers/providers/magenta2/vod_manager.py +++ b/lib/streaming_providers/providers/magenta2/vod_manager.py @@ -264,6 +264,38 @@ class VodManager: ) return None + def _get_with_serial( + self, url: str, params: Dict, serial_number: str + ) -> Optional[Dict]: + """ + Perform an authenticated GET and inject ``x-stbserialnumber`` alongside + the standard Bearer / x-mpx-authorization headers. + + The personal-bar (DocumentGroupRedirect → PersonalBar) endpoints require + the device serial number header in addition to the Bearer token — omitting + it causes the server to return an empty or error response. + """ + headers: Dict[str, str] = {} + if self._auth_headers_callback: + try: + headers = dict(self._auth_headers_callback()) + except Exception as exc: + logger.warning(f"{self._provider}: auth_headers_callback failed: {exc}") + headers["x-stbserialnumber"] = serial_number + try: + response = self._http.get(url, params=params, headers=headers) + if response and response.status_code == 200: + return response.json() + logger.warning( + f"{self._provider}: VOD request (with serial) failed " + f"[{response.status_code if response else 'no response'}] {url}" + ) + except Exception as exc: + logger.error( + f"{self._provider}: VOD request (with serial) exception for {url}: {exc}" + ) + return None + # ========================================================================= # Private helpers – Personal Bar Discovery # ========================================================================= @@ -273,17 +305,22 @@ class VodManager: Fetch the personal bar and extract the Streaming tile's StructuredGrid URL. Flow: - 1. Build the DocumentGroupRedirect URL using ftv-web client model - (non-web client models get a different/empty personal bar). - 2. Follow the redirect — the API returns {"$type":"redirect","redirectUrl":"..."} - rather than performing an HTTP redirect. - 3. Fetch the redirectUrl to get the PersonalBar with its tiles. - 4. Find the tile whose title matches VOD_STREAMING_TILE_TITLE and + 1. Build the DocumentGroupRedirect URL using the platform's client model + (e.g. ftv-androidtv for Android TV) substituted into home_url. + 2. Send an authenticated GET (Bearer + x-stbserialnumber) with params + matching the real device call: $previewAutoMode, $deviceModel, + $cid (dt-session-id::dt-call-id), $theme, $profile, $redirect. + 3. Follow the API-level redirect — the server returns + {"$type":"redirect","redirectUrl":"..."} instead of an HTTP redirect. + 4. Fetch the redirectUrl (authenticated) to get the PersonalBar tiles. + 5. Find the tile whose title matches VOD_STREAMING_TILE_TITLE and return its onFocus.screen.href. Falls back gracefully at each step so the hardcoded StructuredGrid URL is used when discovery fails. """ + import uuid as _uuid + if not self._home_url: logger.debug( f"{self._provider}: No homeUrl available; " @@ -291,39 +328,41 @@ class VodManager: ) return None - # Always use ftv-web for personal-bar discovery — other client models - # return an empty or different bar that does not contain the Streaming tile. - resolved_home_url = ( - self._home_url - .replace("{clientModel}", self._client_model) - ) + # Substitute the platform's actual client model into the home_url template + # (e.g. ftv-androidtv for Android TV, ftv-web for web). + resolved_home_url = self._home_url.replace("{clientModel}", self._client_model) - # Personal-bar discovery is unauthenticated — no Bearer / x-mpx-authorization - # headers are attached (see _get_no_auth). All other params match the - # real browser call, including the actual subscriber type. + # Compose $cid as "dt-session-id::dt-call-id" — both are random UUIDs + # generated fresh for each request, matching the real device behaviour. + dt_session_id = str(_uuid.uuid4()) + dt_call_id = str(_uuid.uuid4()) + cid = f"{dt_session_id}::{dt_call_id}" + + # Random serial number UUID, as the real device sends its hardware serial. + serial_number = str(_uuid.uuid4()) + + # Params mirror the real Android TV DocumentGroupRedirect request exactly. + # Note: $subscriberType and $reloadAfterChange are NOT sent by real devices; + # $previewAutoMode and $cid are required instead. discovery_params = { - "$reloadAfterChange": "false", + "$previewAutoMode": "false", "$deviceModel": self._device_model, - "$profile": self._profile_name, - "$subscriberType": self._subscriber_type, - "$theme": self._theme_id, + "$cid": cid, "$redirect": "false", - "sid": self._session_id, - "t": str(int(__import__("time").time() * 1000)), + "$theme": self._theme_id, + "$profile": self._profile_name, } - logger.debug( - f"{self._provider}: home_url raw: {self._home_url}" + logger.debug(f"{self._provider}: home_url raw: {self._home_url}") + logger.debug(f"{self._provider}: DocumentGroupRedirect URL: {resolved_home_url}") + logger.debug(f"{self._provider}: DocumentGroupRedirect params: {discovery_params}") + + # The DocumentGroupRedirect endpoint requires authentication — real device + # sends Bearer token + x-stbserialnumber. Inject the serial number as an + # extra header on top of whatever auth_headers_callback provides. + redirect_data = self._get_with_serial( + resolved_home_url, discovery_params, serial_number ) - logger.debug( - f"{self._provider}: DocumentGroupRedirect params: {discovery_params}" - ) - logger.debug( - f"{self._provider}: Fetching personal bar from: {resolved_home_url}" - ) - # Use _get_no_auth so that no Bearer / x-mpx-authorization headers are - # attached — the PersonalBar endpoints reject authenticated requests. - redirect_data = self._get_no_auth(resolved_home_url, discovery_params) if not redirect_data: logger.error(f"{self._provider}: Failed to fetch DocumentGroupRedirect") return None @@ -333,16 +372,16 @@ class VodManager: ) # Step 2: follow the API-level redirect. - # The redirectUrl already contains all required query parameters as - # built server-side — do NOT merge our params on top of it, that would - # duplicate or override values (e.g. sid, t) already present in the URL. + # The redirectUrl already contains all required query parameters built + # server-side — do NOT merge our params on top of it to avoid duplicating + # or overriding values already present in the URL. if redirect_data.get("$type") == "redirect": redirect_url = redirect_data.get("redirectUrl") if not redirect_url: logger.error(f"{self._provider}: Redirect response missing redirectUrl") return None logger.debug(f"{self._provider}: Following personal-bar redirect: {redirect_url}") - bar_data = self._get_no_auth(redirect_url, {}) + bar_data = self._get_with_serial(redirect_url, {}, serial_number) if not bar_data: logger.error(f"{self._provider}: Failed to fetch PersonalBar from redirectUrl") return None