diff --git a/lib/streaming_providers/providers/globals.py b/lib/streaming_providers/providers/globals.py new file mode 100644 index 0000000..a55ff93 --- /dev/null +++ b/lib/streaming_providers/providers/globals.py @@ -0,0 +1,324 @@ +# streaming_providers/providers/globals.py +# ============================================================================ +# Global User Agent Registry +# +# Provides current, browser-accurate user agent strings for all providers. +# Chrome/Chromium version is fetched live from the official version API; +# other browsers track their own versioning but share the same Blink engine +# version where applicable. +# +# Usage: +# from providers.globals import get_user_agent +# +# get_user_agent("linux", "chrome") # Desktop Chrome on Linux +# get_user_agent("windows", "edge") # Desktop Edge on Windows +# get_user_agent("windows", "chrome") # Desktop Chrome on Windows +# get_user_agent("macos", "safari") # Safari on macOS +# get_user_agent("android", "chrome") # Chrome for Android +# get_user_agent("android", "dalvik") # Android system HTTP client +# get_user_agent("ios", "safari") # Mobile Safari on iOS +# get_user_agent("firetv", "chrome") # Fire TV (AFTS) WebView +# ============================================================================ + +from __future__ import annotations + +import json +import threading +import urllib.error +import urllib.request +from functools import lru_cache +from typing import Literal, Optional + +from ..base.utils.logger import logger + +# --------------------------------------------------------------------------- +# Version constants — updated whenever this file is refreshed. +# These serve as fallbacks when the live fetch fails. +# --------------------------------------------------------------------------- + +_CHROME_FALLBACK_VERSION = "146.0.7657.48" # Stable as of 2026-03-10 +_EDGE_FALLBACK_VERSION = "145.0.3800.97" # Stable as of 2026-03-09 +_SAFARI_FALLBACK_VERSION = "18.3" # Safari 18.3 / WebKit 619 +_WEBKIT_FALLBACK_BUILD = "605.1.15" +_IOS_FALLBACK_VERSION = "18.3.2" +_MACOS_FALLBACK_VERSION = "14_4" # Sonoma 14.4 +_ANDROID_FALLBACK_VERSION = "13" + +# Google VersionHistory API — https://versionhistory.googleapis.com/v1/ +# Path structure: /v1/chrome/{platform}/channels/stable/versions +# Platform slugs: "linux" and "win". Edge is Chromium-based and ships the same +# version, so we reuse these fetches for Edge UA strings too. +# No auth required. +_CHROME_LINUX_VERSION_API = ( + "https://versionhistory.googleapis.com/v1/chrome/linux" + "/channels/stable/versions?order_by=version+desc&pageSize=1" +) +_CHROME_WIN_VERSION_API = ( + "https://versionhistory.googleapis.com/v1/chrome/win" + "/channels/stable/versions?order_by=version+desc&pageSize=1" +) + +# Thread-safe one-time fetches — one slot per platform. +# Separate locks so linux and win fetches run concurrently at startup. +_linux_lock = threading.Lock() +_win_lock = threading.Lock() +_linux_ready = threading.Event() +_win_ready = threading.Event() +_linux_version: Optional[str] = None +_win_version: Optional[str] = None + + +def _fetch_version_get(api_url: str, fallback: str, label: str) -> str: + """ + Fetch a version from the Google VersionHistory API (GET). + Response shape: {"versions": [{"version": "X.Y.Z.W", ...}]} + Returns the fallback constant on any network or parse error. + """ + try: + req = urllib.request.Request(api_url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=4) as resp: + data = json.loads(resp.read().decode()) + version = data["versions"][0]["version"] + logger.debug(f"Fetched live {label} version: {version}") + return version + except Exception as exc: + logger.warning( + f"Could not fetch {label} version from API ({exc}); " + f"using fallback {fallback}" + ) + return fallback + + +def _fetch_linux_version() -> str: + """Return the latest stable Chrome/linux version, fetched exactly once.""" + global _linux_version + if not _linux_ready.is_set(): + with _linux_lock: + if not _linux_ready.is_set(): + _linux_version = _fetch_version_get( + _CHROME_LINUX_VERSION_API, _CHROME_FALLBACK_VERSION, "chrome/linux" + ) + _linux_ready.set() + _linux_ready.wait() + return _linux_version # type: ignore[return-value] + + +def _fetch_win_version() -> str: + """Return the latest stable Chrome/win version, fetched exactly once.""" + global _win_version + if not _win_ready.is_set(): + with _win_lock: + if not _win_ready.is_set(): + _win_version = _fetch_version_get( + _CHROME_WIN_VERSION_API, _CHROME_FALLBACK_VERSION, "chrome/win" + ) + _win_ready.set() + _win_ready.wait() + return _win_version # type: ignore[return-value] + + + +# --------------------------------------------------------------------------- +# OS token helpers +# --------------------------------------------------------------------------- + +def _win_token() -> str: + return "Windows NT 10.0; Win64; x64" + + +def _linux_token() -> str: + return "X11; Linux x86_64" + + +def _macos_token() -> str: + return f"Macintosh; Intel Mac OS X {_MACOS_FALLBACK_VERSION}" + + +def _android_token(version: str = _ANDROID_FALLBACK_VERSION) -> str: + return f"Linux; Android {version}" + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +OS = Literal["windows", "linux", "macos", "android", "ios", "firetv", "androidtv", "androidmobile"] +UA = Literal["chrome", "edge", "safari", "dalvik", "firefox"] + + +@lru_cache(maxsize=None) +def get_user_agent(os: OS, browser: UA) -> str: + """ + Return a current, standards-compliant user agent string. + + Parameters + ---------- + os: + Target operating system / device class. + One of: "windows", "linux", "macos", "android", "ios", + "firetv", "androidtv", "androidmobile" + browser: + Browser / HTTP client. + One of: "chrome", "edge", "safari", "dalvik", "firefox" + + Returns + ------- + str + A fully formed User-Agent string. + + Raises + ------ + ValueError + When the (os, browser) combination is unsupported. + """ + cv_linux = _fetch_linux_version() # e.g. "146.0.7657.48" + cv_win = _fetch_win_version() # e.g. "146.0.7657.48" (may differ slightly) + blink = "537.36" # frozen — all Chromium UA strings use this + + # ------------------------------------------------------------------ + # Chrome on desktop + # ------------------------------------------------------------------ + if browser == "chrome": + if os == "linux": + return ( + f"Mozilla/5.0 ({_linux_token()}) " + f"AppleWebKit/{blink} (KHTML, like Gecko) " + f"Chrome/{cv_linux} Safari/{blink}" + ) + if os == "windows": + return ( + f"Mozilla/5.0 ({_win_token()}) " + f"AppleWebKit/{blink} (KHTML, like Gecko) " + f"Chrome/{cv_win} Safari/{blink}" + ) + if os == "macos": + return ( + f"Mozilla/5.0 ({_macos_token()}) " + f"AppleWebKit/{blink} (KHTML, like Gecko) " + f"Chrome/{cv_win} Safari/{blink}" + ) + if os == "android": + av = _ANDROID_FALLBACK_VERSION + return ( + f"Mozilla/5.0 ({_android_token(av)}) " + f"AppleWebKit/{blink} (KHTML, like Gecko) " + f"Chrome/{cv_linux} Mobile Safari/{blink}" + ) + if os == "androidmobile": + av = _ANDROID_FALLBACK_VERSION + return ( + f"Mozilla/5.0 (Linux; Android {av}; SM-G991B) " + f"AppleWebKit/{blink} (KHTML, like Gecko) " + f"Chrome/{cv_linux} Mobile Safari/{blink}" + ) + if os == "androidtv": + return ( + f"Mozilla/5.0 (Linux; Android {_ANDROID_FALLBACK_VERSION}; SHIELD Android TV) " + f"AppleWebKit/{blink} (KHTML, like Gecko) " + f"Chrome/{cv_linux} Safari/{blink}" + ) + if os == "firetv": + return ( + f"Mozilla/5.0 (Linux; Android {_ANDROID_FALLBACK_VERSION}; AFTS Build/PPR1.180610.011) " + f"AppleWebKit/{blink} (KHTML, like Gecko) " + f"Chrome/{cv_linux} Safari/{blink}" + ) + + # ------------------------------------------------------------------ + # Microsoft Edge (Chromium-based) — reuses Chrome versions from the + # same VersionHistory API: win slug for Windows/macOS, linux for Linux. + # ------------------------------------------------------------------ + if browser == "edge": + if os == "windows": + return ( + f"Mozilla/5.0 ({_win_token()}) " + f"AppleWebKit/{blink} (KHTML, like Gecko) " + f"Chrome/{cv_win} Safari/{blink} " + f"Edg/{cv_win}" + ) + if os == "linux": + return ( + f"Mozilla/5.0 ({_linux_token()}) " + f"AppleWebKit/{blink} (KHTML, like Gecko) " + f"Chrome/{cv_linux} Safari/{blink} " + f"Edg/{cv_linux}" + ) + if os == "macos": + return ( + f"Mozilla/5.0 ({_macos_token()}) " + f"AppleWebKit/{blink} (KHTML, like Gecko) " + f"Chrome/{cv_win} Safari/{blink} " + f"Edg/{cv_win}" + ) + + # ------------------------------------------------------------------ + # Apple Safari + # ------------------------------------------------------------------ + if browser == "safari": + if os == "macos": + sv = _SAFARI_FALLBACK_VERSION # "18.3" + wk = _WEBKIT_FALLBACK_BUILD # "605.1.15" + return ( + f"Mozilla/5.0 ({_macos_token()}) " + f"AppleWebKit/{wk} (KHTML, like Gecko) " + f"Version/{sv} Safari/{wk}" + ) + if os == "ios": + ios = _IOS_FALLBACK_VERSION # "18.3.2" + ios_ua = ios.replace(".", "_") + sv = _SAFARI_FALLBACK_VERSION + wk = _WEBKIT_FALLBACK_BUILD + return ( + f"Mozilla/5.0 (iPhone; CPU iPhone OS {ios_ua} like Mac OS X) " + f"AppleWebKit/{wk} (KHTML, like Gecko) " + f"Version/{sv} Mobile/15E148 Safari/{wk}" + ) + + # ------------------------------------------------------------------ + # Android Dalvik (system HTTP client — used by native Android apps) + # ------------------------------------------------------------------ + if browser == "dalvik": + if os in ("android", "androidtv", "androidmobile", "firetv"): + # e.g. Dalvik/2.1.0 (Linux; U; Android 11; SHIELD Android TV …) + build = "RQ1A.210105.003" + return ( + f"Dalvik/2.1.0 (Linux; U; Android {_ANDROID_FALLBACK_VERSION}; " + f"SHIELD Android TV Build/{build})" + ) + + raise ValueError( + f"Unsupported (os={os!r}, browser={browser!r}) combination. " + f"Supported OS values: windows, linux, macos, android, androidtv, androidmobile, ios, firetv. " + f"Supported browser values: chrome, edge, safari, dalvik." + ) + + +def invalidate_version_cache() -> None: + """ + Force a re-fetch of all versions on next call. + Useful for long-running processes that want to pick up a new release. + """ + global _linux_version, _win_version + with _linux_lock: + _linux_version = None + _linux_ready.clear() + with _win_lock: + _win_version = None + _win_ready.clear() + get_user_agent.cache_clear() + + +# --------------------------------------------------------------------------- +# Eager background prefetch — runs once at import time. +# Both platform fetches run concurrently (daemon threads, never block shutdown). +# --------------------------------------------------------------------------- + +def _prefetch_all() -> None: + for target, name in ( + (_fetch_linux_version, "prefetch-chrome-linux"), + (_fetch_win_version, "prefetch-chrome-win"), + ): + threading.Thread(target=target, daemon=True, name=name).start() + + +_prefetch_all() \ No newline at end of file diff --git a/lib/streaming_providers/providers/rtlplus/constants.py b/lib/streaming_providers/providers/rtlplus/constants.py index 5804b5c..57928da 100644 --- a/lib/streaming_providers/providers/rtlplus/constants.py +++ b/lib/streaming_providers/providers/rtlplus/constants.py @@ -3,28 +3,38 @@ RTL+ provider constants and default configurations """ +from ..globals import get_user_agent + +import json +import urllib.request +from ...base.utils.logger import logger + +# Fetch RTL+ client version once at import time. +_RTLPLUS_CONFIG_URL = "https://plus.rtl.de/assets/config/config.json" +_RTLPLUS_CLIENT_VERSION_FALLBACK = "2025.6.26.0" +try: + with urllib.request.urlopen(_RTLPLUS_CONFIG_URL, timeout=4) as _resp: + _RTLPLUS_CLIENT_VERSION = json.loads(_resp.read().decode())["version"] + logger.debug(f"Fetched RTL+ client version: {_RTLPLUS_CLIENT_VERSION}") +except Exception as _exc: + logger.warning(f"Could not fetch RTL+ client version ({_exc}); using fallback {_RTLPLUS_CLIENT_VERSION_FALLBACK}") + _RTLPLUS_CLIENT_VERSION = _RTLPLUS_CLIENT_VERSION_FALLBACK class RTLPlusDefaults: """Default values for RTL+ provider""" RTLPLUS_LOGO = "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f4/RTL%2B_Logo_2021.svg/2560px-RTL%2B_Logo_2021.svg.png" - # Client and version information - CLIENT_VERSION = "2025.6.26.0" - CHROME_VERSION = "121.0.0.0" - CLIENT_ID = "rtlplus-web" - # Device information DEVICE_ID = "8c3f37cc-13a3-4141-bd0f-e4b3673fe5e4" DEVICE_NAME = "Linux Chrome" PLAYREADY_DEVICE_NAME = "Windows Edge" - # User Agent components - USER_AGENT = f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{CHROME_VERSION} Safari/537.36" - - # PlayReady-specific user agent (Edge/Windows as seen in license acquisition requests) - PLAYREADY_EDGE_VERSION = "145.0.0.0" - PLAYREADY_USER_AGENT = f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{PLAYREADY_EDGE_VERSION} Safari/537.36 Edg/{PLAYREADY_EDGE_VERSION}" + # User agents — sourced from globals.get_user_agent() so versions stay current. + # Windows Chrome is used for standard requests; Windows Edge for PlayReady + # license acquisition (SOAP/XML), which requires an Edge UA. + USER_AGENT = get_user_agent("windows", "chrome") + PLAYREADY_USER_AGENT = get_user_agent("windows", "edge") # PlayReady SOAP action for license acquisition PLAYREADY_SOAP_ACTION = "http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense" @@ -34,7 +44,11 @@ class RTLPlusDefaults: PLATFORM_ANDROID = "android" PLATFORM_IOS = "ios" PLATFORM_SMART_TV = "smarttv" - PLATFORM_DEFAULT = PLATFORM_WEB + PLATFORM_DEFAULT = PLATFORM_ANDROID + + # Client and version information + CLIENT_VERSION = _RTLPLUS_CLIENT_VERSION + CLIENT_ID = f"rtlplus-{PLATFORM_DEFAULT}" # API endpoints AUTH_BASE_URL = "https://auth.rtl.de/auth/realms/rtlplus/protocol/openid-connect" @@ -80,12 +94,12 @@ class RTLPlusDefaults: # WatchPlayerConfigV3 returns stream URLs + DRM config directly from GraphQL # (replaces the watch-playout-variants REST endpoint for VOD) - VOD_PLATFORM_GRAPHQL = "WEB" + VOD_PLATFORM_GRAPHQL = PLATFORM_DEFAULT.upper() # Wurstland — secondary stream resolver, returns DASH/HLS URLs # GET /config/{rrn}/{platform} VOD_WURSTLAND_CONFIG_URL = "https://wurstland.plus.rtl.de/config/{rrn}/{platform}" - VOD_WURSTLAND_PLATFORM = "WEB" + VOD_WURSTLAND_PLATFORM = PLATFORM_DEFAULT.upper() # --------------------------------------------------------------------------- # VOD — root category definition @@ -143,6 +157,7 @@ class RTLPlusGraphQL: # sha256 hashes keyed by operationName (captured from browser network traffic) HASHES: dict[str, str] = { + "LiveTvStations": "845cf56a2a78110a0f978c1a2af2bc7f9a1c937d0f324ffaf852a9a4414c8485", "ExploreWidgetWatch": "724f21ab86aa3f8c57673a3b346cf119f6a155bf82bb0201fd3b18e28e44f1ed", "TopicWorlds": "3dbde4c45532f4bdb0a1d7c210db43f0888f8a82f4aab43f7a90f3c4762d8ff7", @@ -202,8 +217,7 @@ class RTLPlusGraphQL: @classmethod def topic_worlds(cls, take: int = 100, offset: int = 0) -> dict: - """ - TopicWorlds — browse all genre/topic worlds. + """TopicWorlds — browse all genre/topic worlds. Confirmed from live traffic: response key: data.topicWorldsV2.elements[] (NOT topicWorlds.items[]) @@ -275,11 +289,6 @@ class RTLPlusHeaders: return { "User-Agent": user_agent or RTLPlusDefaults.USER_AGENT, "Accept": "application/json", - # 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8', - # 'Accept-Encoding': 'gzip, deflate, br', - # 'DNT': '1', - # 'Connection': 'keep-alive', - # 'Upgrade-Insecure-Requests': '1' } @staticmethod @@ -307,7 +316,7 @@ class RTLPlusHeaders: headers.update( { "Content-Type": "application/json", - "Rtlplus-Client-Id": RTLPlusDefaults.CLIENT_ID, + "Rtlplus-Client-Id": f"rci:rtlplus:{RTLPlusDefaults.PLATFORM_DEFAULT}", "Rtlplus-Referrer": "", "Rtlplus-Client-Version": client_version or RTLPlusDefaults.CLIENT_VERSION, } @@ -329,8 +338,7 @@ class RTLPlusHeaders: client_version: str = None, user_agent: str = None, ) -> dict: - """ - Get headers for the editorial GraphQL endpoint (events / ExploreWidgetWatch). + """Get headers for the editorial GraphQL endpoint (events / ExploreWidgetWatch). Extends the standard API headers with the optional ``Rtlplus-Profile`` JWT that the browser sends when a user profile is active. The header @@ -413,7 +421,6 @@ class RTLPlusConfig: # Core settings (can be overridden) self.client_version = config.get("client_version", RTLPlusDefaults.CLIENT_VERSION) - self.chrome_version = config.get("chrome_version", RTLPlusDefaults.CHROME_VERSION) self.device_id = config.get("device_id", RTLPlusDefaults.DEVICE_ID) self.user_agent = config.get("user_agent", RTLPlusDefaults.USER_AGENT) self.platform = config.get("platform", RTLPlusDefaults.PLATFORM_DEFAULT) @@ -439,7 +446,6 @@ class RTLPlusConfig: return self.manifest_endpoint.format( channel_id=content_id, platform=self.platform ) - # episode, movie, clip — full RRN passed directly return self.vod_playout_endpoint.format( content_id=content_id, platform=self.platform )