Add discovery

This commit is contained in:
Nirvana
2026-03-10 16:19:50 +01:00
parent 97f2e29d5f
commit 4b30cb0f55
2 changed files with 356 additions and 26 deletions
@@ -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()
@@ -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
)