mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-21 23:32:48 +02:00
225 lines
8.9 KiB
Python
225 lines
8.9 KiB
Python
# streaming_providers/providers/zdf/constants.py
|
|
"""
|
|
ZDF constants -- the SINGLE SOURCE OF TRUTH for every URL, header value,
|
|
identifier and default of this provider (README §4, §10).
|
|
|
|
Identity rule: ONE id, used everywhere
|
|
directory name == AVAILABLE_PROVIDERS key == provider_name
|
|
== PROVIDER_NAME == credentials / session / enable-setting key
|
|
Never rename it after release.
|
|
|
|
Upstream flow (all against api.zdf.de, no user account, no DRM):
|
|
0. GET https://www.zdf.de/ -> videoToken + appToken (auth.py, cached)
|
|
1. GET /graphql getActiveLiveStreams (persisted query) -> channel list
|
|
2. POST /graphql VideoByCanonical (only if the ptmd path is not cached)
|
|
3. GET /tmd/2/<player>/live/ptmd/<id> -> HLS manifest URL
|
|
|
|
The ``api-auth`` bearer values are the PUBLIC, expiring client tokens of the
|
|
ZDF web player, not user credentials. auth.py fetches them from the web page;
|
|
the static values below are only the FALLBACK when that fetch fails (they
|
|
stop working when ZDF rotates them). Override via the provider config dict
|
|
(``api_token`` / ``list_token`` / ``app_id``). Tokens must never be logged.
|
|
"""
|
|
|
|
import json
|
|
from typing import Optional
|
|
|
|
|
|
class ZdfDefaults:
|
|
PROVIDER_NAME = "zdf"
|
|
PROVIDER_LABEL = "ZDF" # shown in the UI, without country
|
|
PROVIDER_LOGO = "" # no verified logo URL available
|
|
|
|
# Single entry: the registry pins the construction country to "DE"
|
|
# (UPPERCASE); the provider lower-cases it for the storage managers.
|
|
SUPPORTED_COUNTRIES = ("DE",)
|
|
|
|
# --- Hosts / paths -------------------------------------------------
|
|
BASE_URL = "https://api.zdf.de"
|
|
ORIGIN = "https://www.zdf.de"
|
|
REFERER = "https://www.zdf.de/"
|
|
PATH_GRAPHQL = "/graphql"
|
|
|
|
# --- GraphQL (channel list = persisted query) -------------------------
|
|
LIST_OPERATION = "getActiveLiveStreams"
|
|
LIST_QUERY_HASH = "b2a5abbc0551e5206f26c571d9ed3a413c45513ec099785ad9280718c9d82423"
|
|
CLIENT_LIBRARY = {"name": "@apollo/client", "version": "4.1.9"}
|
|
|
|
# --- PTMD (playout metadata) -----------------------------------------
|
|
# The channel list delivers e.g. "/tmd/2/{playerId}/live/ptmd/247onAir-203";
|
|
# the web player substitutes the placeholder with this id.
|
|
PLAYER_ID = "ngplayer_2_5"
|
|
PLAYER_ID_PLACEHOLDER = "{playerId}"
|
|
# Real 24/7 channels point to ".../live/ptmd/247onAir-<n>". Event streams
|
|
# (sports, services) and test items do not, and are not channels.
|
|
LIVE247_MARKER = "/live/ptmd/247onAir-"
|
|
|
|
# --- HLS selection -------------------------------------------------------
|
|
# The 24/7 channels were only seen with HLS formitaeten (no DASH), so
|
|
# HLS is returned. A formitaet counts as HLS when its mimeType contains
|
|
# "mpegurl" or its type contains "m3u8". Matching is case-insensitive.
|
|
HLS_MIME_MARKER = "mpegurl"
|
|
HLS_TYPE_MARKER = "m3u8"
|
|
# Preferred quality entries, best first. "auto" is the master ZDF itself
|
|
# lists first (and the one the web player / other clients use).
|
|
HLS_QUALITY_ORDER = ("auto", "veryhigh", "high", "hd", "med", "low")
|
|
AUDIO_CLASS_PREFERRED = "main" # not "ad" (audio description)
|
|
AUDIO_LANGUAGE_PREFERRED = "deu"
|
|
|
|
# --- Token source ---------------------------------------------------------
|
|
# The page embeds the tokens in Next.js "flight" chunks:
|
|
# self.__next_f.push([1,"...\"videoToken\":{\"apiToken\":\"..\",\"expiresAt\":\"..\"}..."])
|
|
# videoToken -> GraphQL POST + PTMD ("api"), appToken -> channel list ("app").
|
|
WEB_URL = "https://www.zdf.de/"
|
|
TOKEN_KIND_API = "api"
|
|
TOKEN_KIND_LIST = "app"
|
|
FLIGHT_TOKEN_NAMES = {"videoToken": "api", "appToken": "app"}
|
|
# Refresh this long before the token really expires.
|
|
TOKEN_REFRESH_BUFFER_SECONDS = 300
|
|
# After a failed token fetch use the static fallback and do not retry
|
|
# before this many seconds have passed.
|
|
TOKEN_FETCH_BACKOFF_SECONDS = 60
|
|
|
|
# --- Fallback client identity (override via config, never log) --------------
|
|
LIST_TOKEN = "aa3noh4ohz9eeboo8shiesheec9ciequ9Quah7el"
|
|
API_TOKEN = "ahBaeMeekaiy5ohsai4bee4ki6Oopoi5quailieb"
|
|
APP_ID = "ffw-mt-web-37e8d3ae"
|
|
|
|
# --- Headers -------------------------------------------------------
|
|
USER_AGENT = (
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36"
|
|
)
|
|
ACCEPT_LIST = "application/graphql-response+json,application/json;q=0.9"
|
|
ACCEPT_PTMD = "application/vnd.de.zdf.v1.0+json"
|
|
ACCEPT_LANGUAGE = "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7"
|
|
TIMEOUT = 30
|
|
|
|
# --- Caches --------------------------------------------------------
|
|
# canonical -> resolved ptmd path (filled by get_channels, so most zaps
|
|
# need no VideoByCanonical round-trip).
|
|
PTMD_PATH_CACHE_TTL = 300.0
|
|
|
|
# --- Cosmetics -----------------------------------------------------
|
|
# `tvService` of the list is used as channel name; these fix the spelling.
|
|
CHANNEL_NAME_OVERRIDES = {
|
|
"KI.KA": "KiKA",
|
|
"PHOENIX": "phoenix",
|
|
"arte": "ARTE",
|
|
}
|
|
|
|
|
|
# Minimal query: only the fields the provider reads (all of them appear in the
|
|
# web player's own VideoByCanonical request).
|
|
VIDEO_BY_CANONICAL_QUERY = """
|
|
query VideoByCanonical($canonical: String!) {
|
|
videoByCanonical(canonical: $canonical) {
|
|
id
|
|
canonical
|
|
title
|
|
currentMediaType
|
|
currentMedia {
|
|
nodes {
|
|
ptmdTemplate
|
|
... on LiveMedia {
|
|
geoLocation
|
|
tvService
|
|
encryption
|
|
liveMediaType
|
|
}
|
|
id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
class ZdfConfig:
|
|
"""
|
|
Per-instance configuration.
|
|
|
|
ONE instance is created by the provider and SHARED with auth and the
|
|
manager. Never rebuild a second config from a subset of values.
|
|
"""
|
|
|
|
def __init__(self, config_dict: Optional[dict] = None):
|
|
config = config_dict or {}
|
|
self.base_url = config.get("base_url", ZdfDefaults.BASE_URL)
|
|
self.user_agent = config.get("user_agent", ZdfDefaults.USER_AGENT)
|
|
self.timeout = config.get("timeout", ZdfDefaults.TIMEOUT)
|
|
self.api_token = config.get("api_token", ZdfDefaults.API_TOKEN)
|
|
self.list_token = config.get("list_token", ZdfDefaults.LIST_TOKEN)
|
|
self.app_id = config.get("app_id", ZdfDefaults.APP_ID)
|
|
|
|
# ----- URL / query builders -----------------------------------------
|
|
def url(self, path: str, **fmt) -> str:
|
|
return f"{self.base_url}{path.format(**fmt)}"
|
|
|
|
def list_query_params(self) -> dict:
|
|
"""Query string of the persisted-query GET (compact JSON, like the web client)."""
|
|
extensions = {
|
|
"clientLibrary": ZdfDefaults.CLIENT_LIBRARY,
|
|
"persistedQuery": {"version": 1, "sha256Hash": ZdfDefaults.LIST_QUERY_HASH},
|
|
}
|
|
return {
|
|
"operationName": ZdfDefaults.LIST_OPERATION,
|
|
"variables": "{}",
|
|
"extensions": json.dumps(extensions, separators=(",", ":")),
|
|
}
|
|
|
|
# ----- Header builders ------------------------------------------------
|
|
# The token always comes from auth.py (fresh or fallback), never from here.
|
|
def _base_headers(self) -> dict:
|
|
return {
|
|
"User-Agent": self.user_agent,
|
|
"Accept-Language": ZdfDefaults.ACCEPT_LANGUAGE,
|
|
"Origin": ZdfDefaults.ORIGIN,
|
|
"Referer": ZdfDefaults.REFERER,
|
|
}
|
|
|
|
def page_headers(self) -> dict:
|
|
"""Plain page fetch of the token source (no api-auth)."""
|
|
return {
|
|
"User-Agent": self.user_agent,
|
|
"Accept": "text/html,application/xhtml+xml",
|
|
"Accept-Language": ZdfDefaults.ACCEPT_LANGUAGE,
|
|
}
|
|
|
|
def list_headers(self, token: str) -> dict:
|
|
"""Persisted-query GET (channel list): appToken + app id."""
|
|
headers = self._base_headers()
|
|
headers["Accept"] = ZdfDefaults.ACCEPT_LIST
|
|
headers["Content-Type"] = "application/json"
|
|
headers["api-auth"] = f"Bearer {token}"
|
|
headers["zdf-app-id"] = self.app_id
|
|
return headers
|
|
|
|
def graphql_headers(self, token: str) -> dict:
|
|
"""VideoByCanonical POST: videoToken."""
|
|
headers = self._base_headers()
|
|
headers["Accept"] = "*/*"
|
|
headers["Content-Type"] = "application/json"
|
|
headers["api-auth"] = f"Bearer {token}"
|
|
return headers
|
|
|
|
def ptmd_headers(self, token: str) -> dict:
|
|
"""PTMD GET: videoToken."""
|
|
headers = self._base_headers()
|
|
headers["Accept"] = ZdfDefaults.ACCEPT_PTMD
|
|
headers["api-auth"] = f"Bearer {token}"
|
|
return headers
|
|
|
|
def stream_headers(self) -> dict:
|
|
"""
|
|
Headers for manifest / segment fetches by the player (CDN).
|
|
|
|
Deliberately WITHOUT api-auth / zdf-app-id: the CDN does not need them
|
|
and sending them would leak the client token to the edge.
|
|
"""
|
|
return {
|
|
"User-Agent": self.user_agent,
|
|
"Origin": ZdfDefaults.ORIGIN,
|
|
"Referer": ZdfDefaults.REFERER,
|
|
}
|