Add discovery

This commit is contained in:
Nirvana
2026-02-24 16:30:36 +01:00
parent 11ff0fdfdd
commit 11142d5e71
3 changed files with 272 additions and 74 deletions
@@ -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",
@@ -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(
-1
View File
@@ -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