diff --git a/lib/streaming_providers/providers/magenta2/constants.py b/lib/streaming_providers/providers/magenta2/constants.py index ff8cbb3..ad7d83d 100644 --- a/lib/streaming_providers/providers/magenta2/constants.py +++ b/lib/streaming_providers/providers/magenta2/constants.py @@ -297,4 +297,15 @@ VOD_PREFIX_MOVIE_SH = "GN_SH" # Name of the personal-bar tile that leads to the VOD StructuredGrid. # Looked up by exact title match in the primary.tiles list returned by homeUrl. -VOD_STREAMING_TILE_TITLE = "Streaming" \ No newline at end of file +# Ordered list of personal-bar tile titles that lead to the VOD StructuredGrid. +# Tried in order — first match wins. Different user subscriptions/locales +# may show different tile names (e.g. "MagentaTV+" instead of "Streaming"). +VOD_STREAMING_TILE_TITLES = [ + "Streaming", # Default / standard subscription + "MagentaTV+", # Alternative name observed in some accounts + "Heimkino", # Fallback seen in basic-tier bars +] + +# Keep the singular alias for backwards compatibility with any external code +# that still imports VOD_STREAMING_TILE_TITLE directly. +VOD_STREAMING_TILE_TITLE = VOD_STREAMING_TILE_TITLES[0] \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/provider.py b/lib/streaming_providers/providers/magenta2/provider.py index 1a4bc22..89ba7ad 100644 --- a/lib/streaming_providers/providers/magenta2/provider.py +++ b/lib/streaming_providers/providers/magenta2/provider.py @@ -75,9 +75,13 @@ class Magenta2Provider(StreamingProvider): ) self.terminal_type = self.platform_config["terminal_type"] - # Generate session ID and device ID + # Generate session ID, device ID, and serial number. + # serial_number is stable for the lifetime of this provider instance — + # it mimics the hardware serial that a real device would send and must + # be consistent across all requests in the same session. self.session_id = self._generate_uuid() self.device_id = self._generate_device_id() + self.serial_number = self._generate_uuid() # Setup proxy configuration self.proxy_config = ( @@ -277,6 +281,7 @@ class Magenta2Provider(StreamingProvider): bootstrap=self.endpoint_manager.config.bootstrap, provider_config=self.endpoint_manager.config, session_id=self.session_id, + serial_number=self.serial_number, auth_headers_callback=self._vod_auth_headers, ) logger.info("✓ VodManager initialized with discovered config") @@ -1116,37 +1121,81 @@ class Magenta2Provider(StreamingProvider): def _vod_auth_headers(self) -> Dict[str, str]: """ - Build authentication headers required by authenticated VOD endpoints: - vodproductinformation (wcps.t-online.de/uspip/...) - VodPlayer (wcps.t-online.de/vops/...) + Build authentication headers required by authenticated VOD endpoints + (vodproductinformation, VodPlayer, PersonalBar). - Required headers (observed from browser traffic): - Authorization: Bearer - x-mpx-authorization: Basic (account_uri:jwt b64) - x-dt-session-id: - x-dt-call-id: - origin / referer: magenta.tv + Headers are platform-dependent: + + ftv-web + ------- + Authorization: Bearer + x-mpx-authorization: Basic + x-dt-session-id: + x-dt-call-id: + origin: https://www.magenta.tv + referer: https://www.magenta.tv/ + user-agent: + x-permissionflagpersonalizeduireco: false + + ftv-android / ftv-androidtv (and variants) + ------------------------------------------- + Authorization: Bearer + x-mpx-authorization: Basic + x-stbserialnumber: + dt-session-id: (no x- prefix) + dt-call-id: (no x- prefix) + user-agent: + accept-encoding: gzip + + Note: _get_with_serial() in VodManager may further override dt-call-id + and x-stbserialnumber per-request; the values set here act as a + sensible default for plain _get() / _get_auth() calls. """ persona_token = self._ensure_authenticated() - # The persona_token is already Base64(account_uri:jwt) — used as-is - # for x-mpx-authorization. For Authorization we need just the raw JWT. + # persona_token is Base64(account_uri:jwt) — used as-is for + # x-mpx-authorization. Strip to just the raw JWT for Authorization. persona_jwt = self._extract_persona_jwt_from_token(persona_token) - headers = { - "x-mpx-authorization": f"Basic {persona_token}", - "x-dt-session-id": self.session_id, - "x-dt-call-id": self._generate_call_id(), - "origin": "https://www.magenta.tv", - "referer": "https://www.magenta.tv/", - "User-Agent": self.platform_config["user_agent"], - "Accept": "application/json", - "x-permissionflagpersonalizeduireco": "false", - } - if persona_jwt: - headers["Authorization"] = f"Bearer {persona_jwt}" + auth_value = ( + f"Bearer {persona_jwt}" if persona_jwt else f"Basic {persona_token}" + ) + + # Determine which header flavour to use based on the client_model + # resolved at init time (ftv-web vs everything else). + client_model: str = ( + self.provider_config.bootstrap.client_model + if self.provider_config and self.provider_config.bootstrap + else f"ftv-{self.platform}" + ) + is_web = client_model == "ftv-web" + + if is_web: + headers: Dict[str, str] = { + "Authorization": auth_value, + "x-mpx-authorization": f"Basic {persona_token}", + "x-dt-session-id": self.session_id, + "x-dt-call-id": self._generate_call_id(), + "origin": "https://www.magenta.tv", + "referer": "https://www.magenta.tv/", + "user-agent": self.platform_config["user_agent"], + "accept": "*/*", + "accept-encoding": "gzip, deflate, br, zstd", + "accept-language": "de-DE,de;q=0.9", + "x-permissionflagpersonalizeduireco": "false", + } else: - # Fallback: some endpoints accept Basic auth too - headers["Authorization"] = f"Basic {persona_token}" + # Android TV / Android Mobile / ATV-Launcher / iOS all use the + # non-prefixed dt-* header names and expose the serial number. + headers = { + "Authorization": auth_value, + "x-mpx-authorization": f"Basic {persona_token}", + "x-stbserialnumber": self.serial_number, + "dt-session-id": self.session_id, + "dt-call-id": self._generate_call_id(), + "user-agent": self.platform_config["user_agent"], + "accept-encoding": "gzip", + } + return headers def get_vod_category(self, category_path, **kwargs): diff --git a/lib/streaming_providers/providers/magenta2/vod_manager.py b/lib/streaming_providers/providers/magenta2/vod_manager.py index c9a37ad..74d3350 100644 --- a/lib/streaming_providers/providers/magenta2/vod_manager.py +++ b/lib/streaming_providers/providers/magenta2/vod_manager.py @@ -57,7 +57,7 @@ from .constants import ( VOD_PREFIX_EPISODE, VOD_PREFIX_SEASON, VOD_PREFIX_SERIES, - VOD_STREAMING_TILE_TITLE, + VOD_STREAMING_TILE_TITLES, ) class VodManager: @@ -94,6 +94,7 @@ class VodManager: bootstrap=None, provider_config=None, session_id: Optional[str] = None, + serial_number: Optional[str] = None, preferred_quality: str = "HD", auth_headers_callback=None, ): @@ -101,6 +102,12 @@ class VodManager: self._provider = provider_name self._provider_config = provider_config self._session_id: str = session_id or "" + # Stable serial number for the lifetime of this manager instance. + # Passed in from the provider so it stays consistent across all + # requests (auth headers callback, _get_with_serial calls, etc.). + # Falls back to a fresh UUID only when not supplied (e.g. unit tests). + import uuid as _uuid_mod + self._serial_number: str = serial_number or str(_uuid_mod.uuid4()) # Normalise to uppercase; fall back to "HD" for unknown values. _q = (preferred_quality or "HD").upper() self._preferred_quality: str = _q if _q in self._QUALITY_FALLBACK else "HD" @@ -356,9 +363,10 @@ class VodManager: dt_call_id_1 = str(_uuid.uuid4()) cid = f"{dt_session_id}::{dt_call_id_1}" - # Random serial number UUID — the real device sends its hardware serial, - # but any stable UUID is accepted. - serial_number = str(_uuid.uuid4()) + # Use the stable serial number that was set at construction time. + # Generating a new UUID here on every call would cause the server to + # treat each request as a different device, breaking session correlation. + serial_number = self._serial_number # Params mirror the real Android TV DocumentGroupRedirect request exactly. # Note: $subscriberType and $reloadAfterChange are NOT sent by real devices; @@ -457,15 +465,23 @@ class VodManager: f"{self._provider}: Personal bar tiles: " f"{[t.get('title') for t in tiles]}" ) - for tile in tiles: - if tile.get("title") == VOD_STREAMING_TILE_TITLE: + # Build a lookup so we can find the first matching tile by title + # regardless of which subscription/locale variant the user has. + tile_by_title = {t.get("title"): t for t in tiles} + for candidate in VOD_STREAMING_TILE_TITLES: + tile = tile_by_title.get(candidate) + if tile: href = tile.get("onFocus", {}).get("screen", {}).get("href") if href: - logger.debug(f"{self._provider}: Found Streaming grid URL: {href}") + logger.debug( + f"{self._provider}: Found VOD grid tile '{candidate}': {href}" + ) return href logger.warning( - f"{self._provider}: '{VOD_STREAMING_TILE_TITLE}' tile not found in personal bar" + f"{self._provider}: No VOD tile found in personal bar " + f"(tried: {VOD_STREAMING_TILE_TITLES}); " + f"available: {list(tile_by_title.keys())}" ) return None