diff --git a/lib/streaming_providers/providers/discovery/auth.py b/lib/streaming_providers/providers/discovery/auth.py index 51709bc..3bbdc62 100644 --- a/lib/streaming_providers/providers/discovery/auth.py +++ b/lib/streaming_providers/providers/discovery/auth.py @@ -264,16 +264,22 @@ class DiscoveryAuthenticator(BaseAuthenticator): http_manager=None, proxy_config: Optional[ProxyConfig] = None, platform_os: Optional[PlatformOS] = None, + device_id: Optional[str] = None, ): self.country = country self.home_market = HOME_MARKET_MAPPING.get(country, "emea") self.tenant = DEFAULT_TENANT self.env = DEFAULT_ENV - self.device_id = DEFAULT_DEVICE_ID # OS platform — controls User-Agent, x-disco-client, x-device-info self.platform_os: PlatformOS = platform_os if platform_os is not None else DEFAULT_PLATFORM_OS + # Device ID — prefer injected value (from provider via settings_manager), + # fall back to _load_or_create_device_id() for standalone usage. + # Note: set to sentinel now; resolved after super().__init__() below + # so that self.settings_manager is available for the fallback path. + self._injected_device_id = device_id + # Session headers storage (from /token 400 response) self._session_state: Optional[str] = None self._disco_id: Optional[str] = None @@ -311,6 +317,15 @@ class DiscoveryAuthenticator(BaseAuthenticator): logger.debug(f"Discovery __init__ post-super: self.credentials={type(self.credentials).__name__}") + # Resolve device_id now that super().__init__() has run and + # self.settings_manager (set by BaseAuthenticator) is available. + if self._injected_device_id: + self.device_id = self._injected_device_id + logger.debug(f"Using injected device_id: {self.device_id}") + else: + self.device_id = self._load_or_create_device_id() + del self._injected_device_id # clean up sentinel + # If no credentials were provided at construction time, attempt to load # from storage. Promote here too — the credential manager always returns # bare base-class instances. @@ -355,6 +370,35 @@ class DiscoveryAuthenticator(BaseAuthenticator): return self._http_manager + def _load_or_create_device_id(self) -> str: + """ + Return a stable device ID for this installation. + + Intended to be called after the settings_manager is available. + In normal operation the provider injects the device_id directly via + the constructor, so this is only used as a fallback when the + authenticator is constructed standalone (e.g. in tests). + + Falls back to DEFAULT_DEVICE_ID if no settings manager is available. + + Returns: + UUID string (with hyphens) to use as device ID + """ + # BaseAuthenticator stores settings_manager as self.settings_manager + # (no underscore prefix) when passed via super().__init__() + sm = getattr(self, "settings_manager", None) + try: + if sm: + device_id = sm.get_device_id("discovery", self.country) + if device_id: + logger.debug(f"Loaded device_id from settings: {device_id}") + return device_id + except Exception as e: + logger.debug(f"Could not load device_id from settings manager: {e}") + + logger.debug("No settings manager available, using DEFAULT_DEVICE_ID") + return DEFAULT_DEVICE_ID + def _discover_endpoints(self, auth_headers: Dict[str, str]) -> None: """ Discover API endpoints from bootstrap using an authenticated session. diff --git a/lib/streaming_providers/providers/discovery/constants.py b/lib/streaming_providers/providers/discovery/constants.py index b267a8d..4cb86aa 100644 --- a/lib/streaming_providers/providers/discovery/constants.py +++ b/lib/streaming_providers/providers/discovery/constants.py @@ -57,7 +57,7 @@ class PlatformOS(str, Enum): # Active platform — change this single constant to switch all OS-dependent behaviour. # LINUX preserves existing behaviour exactly; WINDOWS mirrors a real Edge/Windows client. -DEFAULT_PLATFORM_OS: Final[PlatformOS] = PlatformOS.WINDOWS +DEFAULT_PLATFORM_OS: Final[PlatformOS] = PlatformOS.LINUX # ============================================================================ @@ -186,6 +186,8 @@ DISCOVERY_DEVICE_INFO_TEMPLATE_LINUX: Final[str] = ( DISCOVERY_DEVICE_INFO_TEMPLATE_WINDOWS: Final[str] = ( f"dplus/{DISCOVERY_CLIENT_VERSION} (desktop/desktop; Windows/NT 10.0; {{device_id}}/{{session_id}})" ) +# Backward-compatible alias — resolves to Linux template (original behaviour) +DISCOVERY_DEVICE_INFO_TEMPLATE: Final[str] = DISCOVERY_DEVICE_INFO_TEMPLATE_LINUX # x-wbd-device-consent header value DISCOVERY_DEVICE_CONSENT: Final[str] = "gpc=0" @@ -228,6 +230,8 @@ DISCOVERY_CLIENT_ID_PREFIX: Final[str] = "web1_prd" # os_version encodes the OS: "0.0.0" for Linux web, "NT 10.0" for Windows DISCOVERY_DISCO_CLIENT_LINUX: Final[str] = f"WEB:0.0.0:dplus:{DISCOVERY_CLIENT_VERSION}" DISCOVERY_DISCO_CLIENT_WINDOWS: Final[str] = f"WEB:NT 10.0:dplus:{DISCOVERY_CLIENT_VERSION}" +# Backward-compatible alias — resolves to Linux value (original behaviour) +DISCOVERY_DISCO_CLIENT: Final[str] = DISCOVERY_DISCO_CLIENT_LINUX # Feature flags endpoint — provides hmacKeys, gisdk clientId, arkose config etc. # x-gisdk clientId comes from the response and is session-specific. @@ -240,7 +244,10 @@ DISCOVERY_FEATURE_FLAGS_PAYLOAD: Final[Dict] = { } -def get_default_device_info(platform_os: Optional[PlatformOS] = None) -> Dict[str, any]: +def get_default_device_info( + platform_os: Optional[PlatformOS] = None, + device_id: Optional[str] = None, +) -> Dict[str, any]: """ Return a fresh copy of default device info for the given OS platform. @@ -248,6 +255,9 @@ def get_default_device_info(platform_os: Optional[PlatformOS] = None) -> Dict[st platform_os: Target OS platform. Defaults to DEFAULT_PLATFORM_OS. PlatformOS.LINUX → Chrome on Linux (original behaviour) PlatformOS.WINDOWS → Edge on Windows + device_id: Device ID to embed. Defaults to DEFAULT_DEVICE_ID. + Pass the authenticator's self.device_id so the value + matches what the server tracks per session. Returns: Dictionary containing device information suitable for the playbackInfo body. @@ -255,6 +265,9 @@ def get_default_device_info(platform_os: Optional[PlatformOS] = None) -> Dict[st if platform_os is None: platform_os = DEFAULT_PLATFORM_OS + if device_id is None: + device_id = DEFAULT_DEVICE_ID + if platform_os == PlatformOS.WINDOWS: os_info = {"name": "Windows", "version": "NT 10.0"} browser_info = {"name": "Microsoft Edge", "version": "139.0.0.0"} @@ -264,7 +277,7 @@ def get_default_device_info(platform_os: Optional[PlatformOS] = None) -> Dict[st browser_info = {"name": "Chrome", "version": "144.0.0.0"} return { - "deviceId": DEFAULT_DEVICE_ID, + "deviceId": device_id, "browser": browser_info, "make": "desktop", "model": "desktop", diff --git a/lib/streaming_providers/providers/discovery/provider.py b/lib/streaming_providers/providers/discovery/provider.py index 31e1094..c629023 100644 --- a/lib/streaming_providers/providers/discovery/provider.py +++ b/lib/streaming_providers/providers/discovery/provider.py @@ -189,6 +189,19 @@ class DiscoveryProvider(StreamingProvider): # self-constructs its own SettingsManager via the backward-compat path, # identical to how JoynAuthenticator works. This ensures it always # resolves the correct /config path via the environment variable. + # Resolve stable device ID from settings manager before constructing + # the authenticator — the authenticator intentionally receives + # settings_manager=None so it cannot do this lookup itself. + _device_id = None + if self._settings_manager: + try: + _device_id = self._settings_manager.get_device_id( + "discovery", country + ) + logger.debug(f"Resolved device_id from settings: {_device_id}") + except Exception as e: + logger.debug(f"Could not resolve device_id from settings: {e}") + self.authenticator = DiscoveryAuthenticator( country=country, settings_manager=None, @@ -197,6 +210,7 @@ class DiscoveryProvider(StreamingProvider): proxy_config=self.http_manager.config.proxy_config, credentials=self.credentials, platform_os=self.platform_os, + device_id=_device_id, ) # Authenticate @@ -452,7 +466,10 @@ class DiscoveryProvider(StreamingProvider): "ssaiProvider": {"version": "2.2.0"}, }, "consumptionType": "streaming", - "deviceInfo": get_default_device_info(self.platform_os), + "deviceInfo": get_default_device_info( + self.platform_os, + device_id=self.authenticator.device_id, + ), "editId": edit_id, "capabilities": get_default_capabilities(self.platform_os), "gdpr": False,