diff --git a/lib/streaming_providers/providers/discovery/constants.py b/lib/streaming_providers/providers/discovery/constants.py index 35624d2..abddfe8 100644 --- a/lib/streaming_providers/providers/discovery/constants.py +++ b/lib/streaming_providers/providers/discovery/constants.py @@ -5,7 +5,7 @@ Discovery+ Provider Constants All configuration values for the Discovery+ streaming provider. """ from enum import Enum -from typing import Dict, List, Final +from typing import Dict, List, Final, Optional # ============================================================================ @@ -35,6 +35,7 @@ class DRMSystem(str, Enum): """DRM systems""" WIDEVINE = "widevine" CLEARKEY = "clearkey" + PLAYREADY = "playready" class AuthProvider(str, Enum): @@ -43,6 +44,22 @@ class AuthProvider(str, Enum): ANONYMOUS = "anonymous" +class PlatformOS(str, Enum): + """ + Target OS platform used for device/header spoofing. + + LINUX → Chrome browser, Widevine + ClearKey DRM, x-disco-client os_version="0.0.0" + WINDOWS → Microsoft Edge browser, PlayReady DRM, x-disco-client os_version="NT 10.0" + """ + LINUX = "linux" + WINDOWS = "windows" + + +# 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.LINUX + + # ============================================================================ # Basic Configuration # ============================================================================ @@ -125,8 +142,21 @@ CHANNEL_ITEM_TYPES: Final[List[str]] = ["distributionChannel", "channel", "linea # User Agent Configuration # ============================================================================ -DISCOVERY_USER_AGENT: Final[ - str] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36" +# Per-platform user agent strings. +# LINUX → Chrome on Linux (original behaviour) +# WINDOWS → Edge on Windows (matches real captured HAR traffic) +DISCOVERY_USER_AGENT_LINUX: Final[str] = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36" +) +DISCOVERY_USER_AGENT_WINDOWS: Final[str] = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0" +) + +# Convenience alias — resolves to the Linux UA by default so existing callers +# that reference DISCOVERY_USER_AGENT directly are unaffected. +DISCOVERY_USER_AGENT: Final[str] = DISCOVERY_USER_AGENT_LINUX # ============================================================================ # Device Configuration @@ -148,10 +178,14 @@ DISCOVERY_CLIENT_VERSION: Final[str] = "6.14.0" # x-disco-params header value DISCOVERY_DISCO_PARAMS: Final[str] = "realm=bolt,bid=dplus,features=ar" -# x-device-info header template — format with device_id and session_id -DISCOVERY_DEVICE_INFO_TEMPLATE: Final[str] = ( +# x-device-info header templates — one per OS platform. +# Format placeholders: {device_id}, {session_id} +DISCOVERY_DEVICE_INFO_TEMPLATE_LINUX: Final[str] = ( f"dplus/{DISCOVERY_CLIENT_VERSION} (desktop/desktop; Linux/x86_64; {{device_id}}/{{session_id}})" ) +DISCOVERY_DEVICE_INFO_TEMPLATE_WINDOWS: Final[str] = ( + f"dplus/{DISCOVERY_CLIENT_VERSION} (desktop/desktop; Windows/NT 10.0; {{device_id}}/{{session_id}})" +) # x-wbd-device-consent header value DISCOVERY_DEVICE_CONSENT: Final[str] = "gpc=0" @@ -190,9 +224,10 @@ DISCOVERY_HMAC_KEY: Final[str] = "55eea189-e9b6-479f-bc51-2224cfda56fe" # x-disco-client-id prefix matches hmacKeys "id" field for web DISCOVERY_CLIENT_ID_PREFIX: Final[str] = "web1_prd" -# x-disco-client format: WEB:{os_version}:dplus:{client_version} -# OS version is "0.0.0" for web (not x86_64 as previously assumed) -DISCOVERY_DISCO_CLIENT: Final[str] = f"WEB:0.0.0:dplus:{DISCOVERY_CLIENT_VERSION}" +# x-disco-client header — WEB:{os_version}:dplus:{client_version} +# 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}" # Feature flags endpoint — provides hmacKeys, gisdk clientId, arkose config etc. # x-gisdk clientId comes from the response and is session-specific. @@ -205,23 +240,39 @@ DISCOVERY_FEATURE_FLAGS_PAYLOAD: Final[Dict] = { } -def get_default_device_info() -> Dict[str, any]: +def get_default_device_info(platform_os: Optional[PlatformOS] = None) -> Dict[str, any]: """ - Return a fresh copy of default device info. + Return a fresh copy of default device info for the given OS platform. + + Args: + platform_os: Target OS platform. Defaults to DEFAULT_PLATFORM_OS. + PlatformOS.LINUX → Chrome on Linux (original behaviour) + PlatformOS.WINDOWS → Edge on Windows Returns: - Dictionary containing device information + Dictionary containing device information suitable for the playbackInfo body. """ + if platform_os is None: + platform_os = DEFAULT_PLATFORM_OS + + if platform_os == PlatformOS.WINDOWS: + os_info = {"name": "Windows", "version": "NT 10.0"} + browser_info = {"name": "Microsoft Edge", "version": "139.0.0.0"} + else: + # LINUX — preserves original behaviour exactly + os_info = {"name": "Linux", "version": "0.0.0"} + browser_info = {"name": "Chrome", "version": "144.0.0.0"} + return { "deviceId": DEFAULT_DEVICE_ID, - "browser": {"name": "Chrome", "version": "144.0.0.0"}, + "browser": browser_info, "make": "desktop", "model": "desktop", - "os": {"name": "Linux", "version": "0.0.0"}, + "os": os_info, "platform": "WEB", "deviceType": "web", "player": { - "sdk": {"name": "Beam Player Desktop", "version": "6.14.0"}, + "sdk": {"name": "Beam Player Desktop", "version": DISCOVERY_CLIENT_VERSION}, "mediaEngine": { "name": "GLUON_BROWSER", "version": "7.0.0", @@ -232,13 +283,35 @@ def get_default_device_info() -> Dict[str, any]: } -def get_default_capabilities() -> Dict[str, any]: +def get_default_capabilities(platform_os: Optional[PlatformOS] = None) -> Dict[str, any]: """ - Return a fresh copy of default capabilities. + Return a fresh copy of default capabilities for the given OS platform. + + The DRM advertised differs by platform: + LINUX → ClearKey + Widevine L3 (original behaviour) + WINDOWS → ClearKey + PlayReady SL3000 (matches real Edge/Windows client) + + Args: + platform_os: Target OS platform. Defaults to DEFAULT_PLATFORM_OS. Returns: - Dictionary containing device capabilities + Dictionary containing device capabilities suitable for the playbackInfo body. """ + if platform_os is None: + platform_os = DEFAULT_PLATFORM_OS + + if platform_os == PlatformOS.WINDOWS: + cdm_list = [ + {"drmKeySystem": "clearkey"}, + {"drmKeySystem": "playready", "maxSecurityLevel": "sl3000"}, + ] + else: + # LINUX — preserves original behaviour exactly + cdm_list = [ + {"drmKeySystem": "clearkey"}, + {"drmKeySystem": "widevine", "maxSecurityLevel": "l3"}, + ] + return { "manifests": {"formats": {"dash": {}}}, "codecs": { @@ -270,10 +343,7 @@ def get_default_capabilities() -> Dict[str, any]: }, }, "contentProtection": { - "contentDecryptionModules": [ - {"drmKeySystem": "clearkey"}, - {"drmKeySystem": "widevine", "maxSecurityLevel": "l3"}, - ] + "contentDecryptionModules": cdm_list }, "devicePlatform": { "memory": {"allocatedMemory": 0, "freeAvailableMemory": 1.7976931348623157e308}, @@ -289,6 +359,41 @@ def get_default_capabilities() -> Dict[str, any]: } +def get_disco_client(platform_os: Optional[PlatformOS] = None) -> str: + """ + Return the x-disco-client header value for the given OS platform. + + Args: + platform_os: Target OS platform. Defaults to DEFAULT_PLATFORM_OS. + + Returns: + x-disco-client header string, e.g. "WEB:0.0.0:dplus:6.14.0" + """ + if platform_os is None: + platform_os = DEFAULT_PLATFORM_OS + if platform_os == PlatformOS.WINDOWS: + return DISCOVERY_DISCO_CLIENT_WINDOWS + return DISCOVERY_DISCO_CLIENT_LINUX + + +def get_device_info_template(platform_os: Optional[PlatformOS] = None) -> str: + """ + Return the x-device-info header template for the given OS platform. + The caller must still call .format(device_id=..., session_id=...) on the result. + + Args: + platform_os: Target OS platform. Defaults to DEFAULT_PLATFORM_OS. + + Returns: + x-device-info template string with {device_id} and {session_id} placeholders. + """ + if platform_os is None: + platform_os = DEFAULT_PLATFORM_OS + if platform_os == PlatformOS.WINDOWS: + return DISCOVERY_DEVICE_INFO_TEMPLATE_WINDOWS + return DISCOVERY_DEVICE_INFO_TEMPLATE_LINUX + + # ============================================================================ # Playback Configuration # ============================================================================ @@ -301,18 +406,39 @@ DEFAULT_APPLICATION_SESSION_ID: Final[str] = "74985592-a061-4597-ab5b-51492d0fc2 # ============================================================================ DRM_SYSTEM_WIDEVINE: Final[str] = DRMSystem.WIDEVINE.value +DRM_SYSTEM_PLAYREADY: Final[str] = DRMSystem.PLAYREADY.value -def get_drm_request_headers() -> Dict[str, str]: +def get_user_agent(platform_os: Optional[PlatformOS] = None) -> str: """ - Return a fresh copy of DRM request headers. + Return the User-Agent string for the given OS platform. + + Args: + platform_os: Target OS platform. Defaults to DEFAULT_PLATFORM_OS. Returns: - Dictionary containing DRM request headers + User-Agent header string. + """ + if platform_os is None: + platform_os = DEFAULT_PLATFORM_OS + if platform_os == PlatformOS.WINDOWS: + return DISCOVERY_USER_AGENT_WINDOWS + return DISCOVERY_USER_AGENT_LINUX + + +def get_drm_request_headers(platform_os: Optional[PlatformOS] = None) -> Dict[str, str]: + """ + Return a fresh copy of DRM request headers for the given OS platform. + + Args: + platform_os: Target OS platform. Defaults to DEFAULT_PLATFORM_OS. + + Returns: + Dictionary containing DRM request headers. """ return { "Content-Type": "application/octet-stream", - "User-Agent": DISCOVERY_USER_AGENT, + "User-Agent": get_user_agent(platform_os), "Origin": "https://play.discoveryplus.com", "Referer": "https://play.discoveryplus.com/", "traceparent": "00-aba275155d9ed5c2b54d576682cb30e6-ddfd854be17b3a6e-01", diff --git a/lib/streaming_providers/providers/discovery/provider.py b/lib/streaming_providers/providers/discovery/provider.py index 01c0413..0cb33aa 100644 --- a/lib/streaming_providers/providers/discovery/provider.py +++ b/lib/streaming_providers/providers/discovery/provider.py @@ -27,9 +27,11 @@ from .auth import ( from .constants import ( DEFAULT_COUNTRY, DEFAULT_TENANT, + DEFAULT_PLATFORM_OS, DISCOVERY_LOGO, - DISCOVERY_USER_AGENT, DRMSystem as DiscoveryDRMSystem, + DRM_SYSTEM_PLAYREADY, + PlatformOS, SUPPORTED_AUTH_TYPES, SUPPORTED_COUNTRIES, CMS_INCLUDE_PARAMS, @@ -38,7 +40,10 @@ from .constants import ( CHANNEL_ITEM_TYPES, get_default_capabilities, get_default_device_info, + get_device_info_template, + get_disco_client, get_drm_request_headers, + get_user_agent, ) from .exceptions import ( PlaybackRestrictedException, @@ -56,7 +61,8 @@ class DiscoveryProvider(StreamingProvider): - Multi-country support (EMEA region) - Anonymous and user authentication - Live channels and VOD content - - DRM-protected streams (Widevine) + - DRM-protected streams (Widevine on Linux, PlayReady on Windows) + - OS platform spoofing via PlatformOS (see constants.DEFAULT_PLATFORM_OS) - Provider cache pattern for efficient channel management """ @@ -78,6 +84,7 @@ class DiscoveryProvider(StreamingProvider): settings_manager=None, proxy_config: Optional[ProxyConfig] = None, proxy_url: Optional[str] = None, + platform_os: Optional[PlatformOS] = None, ): """ Initialize Discovery+ provider. @@ -91,6 +98,10 @@ class DiscoveryProvider(StreamingProvider): settings_manager: Settings manager instance proxy_config: Optional proxy configuration proxy_url: Optional proxy URL string + platform_os: OS platform to spoof (PlatformOS.LINUX or PlatformOS.WINDOWS). + Defaults to DEFAULT_PLATFORM_OS from constants. + LINUX → Widevine + Chrome on Linux (original behaviour) + WINDOWS → PlayReady + Edge on Windows Raises: ValueError: If country not supported or credentials missing @@ -103,6 +114,9 @@ class DiscoveryProvider(StreamingProvider): super().__init__(country=country) + # OS platform used for all device/header/DRM spoofing + self.platform_os: PlatformOS = platform_os if platform_os is not None else DEFAULT_PLATFORM_OS + self.auth_type = auth_type self._settings_manager = settings_manager @@ -120,7 +134,7 @@ class DiscoveryProvider(StreamingProvider): proxy_config=proxy_config, proxy_url=proxy_url, config_dir=config_dir, - user_agent=DISCOVERY_USER_AGENT, + user_agent=get_user_agent(self.platform_os), timeout=30, max_retries=3, ) @@ -247,7 +261,7 @@ class DiscoveryProvider(StreamingProvider): """Get headers with authentication""" return self._build_provider_headers( base_headers={ - "User-Agent": DISCOVERY_USER_AGENT, + "User-Agent": get_user_agent(self.platform_os), "Accept": "application/json", "Content-Type": "application/json", }, @@ -437,9 +451,9 @@ class DiscoveryProvider(StreamingProvider): "ssaiProvider": {"version": "2.2.0"}, }, "consumptionType": "streaming", - "deviceInfo": get_default_device_info(), + "deviceInfo": get_default_device_info(self.platform_os), "editId": edit_id, - "capabilities": get_default_capabilities(), + "capabilities": get_default_capabilities(self.platform_os), "gdpr": False, "firstPlay": False, "playbackSessionId": str(uuid.uuid4()), @@ -567,15 +581,28 @@ class DiscoveryProvider(StreamingProvider): """ Extract streaming URLs and DRM info from playback response. + Detects whichever DRM scheme is present in the response — either + 'widevine' (Linux/Chrome) or 'playready' (Windows/Edge) — and + records it as ``drm_system`` so callers can build the correct DRMConfig + without needing to know the active PlatformOS. + Args: playback_data: Playback API response Returns: - Dictionary with manifest_url, license_url, drm_auth, etc. + Dictionary with: + manifest_url – DASH manifest URL + license_url – DRM license server URL (or None) + drm_system – 'widevine' | 'playready' | None + drm_auth – JWT auth token extracted from license URL (or None) + streaming_format – 'dash' (always for Discovery+) + drm_expiration – ISO-8601 expiration string (or None) + fallback_manifest – Fallback manifest URL (or None) """ result = { "manifest_url": None, "license_url": None, + "drm_system": None, "drm_auth": None, "streaming_format": "dash", "drm_expiration": None, @@ -596,22 +623,32 @@ class DiscoveryProvider(StreamingProvider): if fallback_manifest: result["fallback_manifest"] = fallback_manifest.get("url") - # Extract DRM info + # Extract DRM info — check both schemes in priority order. + # The server returns exactly the scheme(s) matching the capabilities + # we advertised in the playback request (widevine for Linux, + # playready for Windows). We probe both so this method stays + # stateless and works regardless of which platform produced the response. drm = playback_data.get("drm", {}) if drm: + result["drm_expiration"] = drm.get("expirationDate") schemes = drm.get("schemes", {}) - widevine = schemes.get("widevine", {}) - if widevine: - result["license_url"] = widevine.get("licenseUrl") - result["drm_expiration"] = drm.get("expirationDate") - # Extract auth token from license URL if present - if result["license_url"] and "auth=" in result["license_url"]: - import urllib.parse - parsed = urllib.parse.urlparse(result["license_url"]) - query = urllib.parse.parse_qs(parsed.query) - if "auth" in query: - result["drm_auth"] = query["auth"][0] + # Preference order: widevine → playready (mirrors Linux default) + scheme_priority = ["widevine", "playready"] + for scheme_name in scheme_priority: + scheme = schemes.get(scheme_name, {}) + if scheme and scheme.get("licenseUrl"): + result["license_url"] = scheme["licenseUrl"] + result["drm_system"] = scheme_name + + # Extract JWT auth token from the license URL if present + if "auth=" in result["license_url"]: + import urllib.parse + parsed = urllib.parse.urlparse(result["license_url"]) + query = urllib.parse.parse_qs(parsed.query) + if "auth" in query: + result["drm_auth"] = query["auth"][0] + break # Stop at first found scheme # Extract CDN info cdn = playback_data.get("cdn", {}) @@ -623,6 +660,61 @@ class DiscoveryProvider(StreamingProvider): return result + def _build_drm_config(self, streaming_data: Dict[str, Any]) -> Optional[DRMConfig]: + """ + Build a DRMConfig from extracted streaming data. + + Selects the correct DRMSystem based on the scheme returned by the + server (recorded in ``streaming_data["drm_system"]``). Falls back to + the active ``self.platform_os`` when the scheme is absent so the caller + never has to make this decision themselves. + + Supported schemes: + 'widevine' → DRMSystem.WIDEVINE (Linux/Chrome path) + 'playready' → DRMSystem.PLAYREADY (Windows/Edge path) + + Args: + streaming_data: Dictionary returned by extract_streaming_data() + + Returns: + DRMConfig instance or None if no license URL is available + """ + license_url = streaming_data.get("license_url") + if not license_url: + return None + + # Resolve DRM system from what the server actually returned, + # falling back to platform_os expectation if not present. + drm_system_str = streaming_data.get("drm_system") + if drm_system_str == "playready": + drm_system = DRMSystem.PLAYREADY + elif drm_system_str == "widevine": + drm_system = DRMSystem.WIDEVINE + else: + # Fallback: derive from active platform + drm_system = ( + DRMSystem.PLAYREADY + if self.platform_os == PlatformOS.WINDOWS + else DRMSystem.WIDEVINE + ) + logger.debug( + f"drm_system not in streaming_data, inferred from platform_os " + f"({self.platform_os.value}): {drm_system.name}" + ) + + license_headers = get_drm_request_headers(self.platform_os) + + return DRMConfig( + system=drm_system, + priority=1, + license=LicenseConfig( + server_url=license_url, + req_headers=json.dumps(license_headers), + req_data="{CHA-RAW}", + use_http_get_request=False, + ), + ) + def populate_streaming_data( self, channels: List[StreamingChannel], @@ -678,8 +770,11 @@ class DiscoveryProvider(StreamingProvider): channel.streaming_format = streaming_data["streaming_format"] if streaming_data["license_url"]: + drm_config = self._build_drm_config(streaming_data) + if drm_config: + channel.drm_config = drm_config channel.license_url = streaming_data["license_url"] - channel.cdm_type = DiscoveryDRMSystem.WIDEVINE.value + channel.cdm_type = streaming_data["drm_system"] # Store DRM token in DiscoveryChannel cache if streaming_data["drm_auth"]: @@ -763,20 +858,10 @@ class DiscoveryProvider(StreamingProvider): # Configure DRM if license URL is present if streaming_data["license_url"]: - license_headers = get_drm_request_headers() - - drm_config = DRMConfig( - system=DRMSystem.WIDEVINE, - priority=1, - license=LicenseConfig( - server_url=streaming_data["license_url"], - req_headers=json.dumps(license_headers), - req_data="{CHA-RAW}", - use_http_get_request=False, - ), - ) - channel.drm_config = drm_config - channel.cdm_type = DiscoveryDRMSystem.WIDEVINE.value + drm_config = self._build_drm_config(streaming_data) + if drm_config: + channel.drm_config = drm_config + channel.cdm_type = streaming_data["drm_system"] return channel @@ -858,20 +943,8 @@ class DiscoveryProvider(StreamingProvider): if not streaming_data["license_url"]: return [] - license_headers = get_drm_request_headers() - - drm_config = DRMConfig( - system=DRMSystem.WIDEVINE, - priority=1, - license=LicenseConfig( - server_url=streaming_data["license_url"], - req_headers=json.dumps(license_headers), - req_data="{CHA-RAW}", - use_http_get_request=False, - ), - ) - - return [drm_config] + drm_config = self._build_drm_config(streaming_data) + return [drm_config] if drm_config else [] except Exception as e: logger.error( diff --git a/requirements.txt b/requirements.txt index a9f8f6f..18e90d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,6 @@ iso8601>=1.1.0 pycountry>=23.12.11 python-dateutil>=2.8.2 urllib3>=2.0.7 -chardet>=5.2.0 pymp4~=1.4.0 protobuf~=6.33.0 pycryptodome~=3.23.0