This commit is contained in:
Nirvana
2026-10-17 08:36:36 +02:00
parent 371f75a6b1
commit 79dc311e51
6 changed files with 651 additions and 0 deletions
@@ -0,0 +1,4 @@
# streaming_providers/providers/zdf/__init__.py
from .provider import ZdfProvider
__all__ = ["ZdfProvider"]
@@ -0,0 +1,37 @@
# streaming_providers/providers/zdf/auth.py
"""
ZDF auth -- Variant A (README §9), degenerate on purpose.
ZDF live TV needs no user account: the API only wants the public web-client
bearer tokens (constants.py / config override). There is nothing to log in to,
nothing to refresh and nothing persisted, so no lock, no expiry and no
retry/backoff exist here. If ZDF ever rotates the tokens, the symptom is an
HTTP 401/403 from the HTTP manager; the fix is a config override.
Satisfies AuthProtocol: get_access_token() / build_headers() / invalidate().
"""
from typing import Any, Dict
class ZdfAuth:
def __init__(self, *, config: Any):
self._config = config
def get_access_token(self) -> str:
return self._config.api_token
def build_headers(self) -> Dict[str, str]:
"""Default API headers (GraphQL POST)."""
return self._config.graphql_headers()
def build_list_headers(self) -> Dict[str, str]:
"""Persisted-query GET: it uses its own client token and app id."""
return self._config.list_headers()
def build_ptmd_headers(self) -> Dict[str, str]:
return self._config.ptmd_headers()
def invalidate(self) -> None:
"""Static public tokens: nothing in memory or in storage to clear."""
return None
@@ -0,0 +1,134 @@
# streaming_providers/providers/zdf/channel_manager.py
"""
ZDF channel manager -- the only manager (live TV, clear DASH, no DRM).
Contract (ChannelManager ABC; README §6.1):
* get_channels() -> List[Channel]
* get_channel_manifest(id) -> DASH MPD url; raises on real failures
* get_channel_manifest_headers -> CDN headers (HEADERS_FROM_MANAGERS = True)
* get_segment_headers -> inherited (= manifest headers)
* get_channel_drm -> inherited ([] = no DRM; ZDF streams are clear)
content_id == the video's `canonical` ("zdf-live-beitrag-100"). It resolves to
a ptmd path via a small TTL cache that get_channels() fills; on a miss one
VideoByCanonical request fills it. Failure semantics:
* canonical unknown / no live media -> NotFoundError
* ptmd without a DASH stream, or encrypted -> ServerError (raised as
ValueError inside transport_errors); the message lists what was offered.
"""
import time
from typing import Any, Dict, List, Optional, Tuple
from ...base.errors import NotFoundError
from ...base.managers import ChannelManager
from ...base.models import Channel
from ...base.utils.logger import logger
from ...base.utils.transport import transport_errors
from .constants import VIDEO_BY_CANONICAL_QUERY, ZdfDefaults
from .models import (
ZdfChannel,
ZdfPlayout,
extract_video_nodes,
is_channel_node,
ptmd_path_from_video,
)
_PROVIDER = ZdfDefaults.PROVIDER_LABEL
class ZdfChannelManager(ChannelManager):
def __init__(
self,
*,
http_manager: Any,
auth: Any,
country: str,
config: Any,
ptmd_cache: Optional[Dict[str, Tuple[str, float]]] = None,
):
# Extra collaborators are keyword-only, stored AFTER super().__init__,
# and super() gets ONLY the four required ones.
super().__init__(
http_manager=http_manager, auth=auth, country=country, config=config
)
# Provider-owned dict, borrowed by reference: canonical -> (ptmd path, ts).
self._ptmd_cache = ptmd_cache if ptmd_cache is not None else {}
# ------------------------------------------------------------------
# Channels
# ------------------------------------------------------------------
def get_channels(self, **kw: Any) -> List[Channel]:
with transport_errors("get_channels", _PROVIDER):
resp = self.http_manager.get(
self.config.url(ZdfDefaults.PATH_GRAPHQL),
params=self.config.list_query_params(),
headers=self.auth.build_list_headers(),
operation="api",
)
nodes = extract_video_nodes(resp.json())
channels: List[ZdfChannel] = []
seen = set()
now = time.time()
for node in nodes:
if not is_channel_node(node):
continue # test items, scheduled shows, event streams: not channels
try:
channel = ZdfChannel.from_api_response(node)
except (KeyError, TypeError, AttributeError, ValueError) as exc:
logger.warning(f"{_PROVIDER}: skipping malformed channel entry: {exc!r}")
continue
if channel.canonical in seen:
continue
seen.add(channel.canonical)
self._ptmd_cache[channel.canonical] = (channel.ptmd_path, now)
channels.append(channel)
return [c.to_channel(ZdfDefaults.PROVIDER_NAME) for c in channels]
def clear_ptmd_cache(self) -> None:
self._ptmd_cache.clear()
# ------------------------------------------------------------------
# canonical -> ptmd path
# ------------------------------------------------------------------
def _ptmd_path(self, canonical: str) -> str:
now = time.time()
cached = self._ptmd_cache.get(canonical)
if cached and (now - cached[1]) < ZdfDefaults.PTMD_PATH_CACHE_TTL:
return cached[0]
with transport_errors(f"VideoByCanonical {canonical}", _PROVIDER):
resp = self.http_manager.post(
self.config.url(ZdfDefaults.PATH_GRAPHQL),
json={
"operationName": "VideoByCanonical",
"query": VIDEO_BY_CANONICAL_QUERY,
"variables": {"canonical": canonical},
},
headers=self.auth.build_headers(),
operation="api",
)
path = ptmd_path_from_video(resp.json())
if path is None:
raise NotFoundError(f"{_PROVIDER}: no live stream for {canonical}")
self._ptmd_cache[canonical] = (path, now)
return path
# ------------------------------------------------------------------
# Manifest / headers
# ------------------------------------------------------------------
def get_channel_manifest(self, content_id: str, **kw: Any) -> Optional[str]:
ptmd_path = self._ptmd_path(content_id)
with transport_errors(f"ptmd for {content_id}", _PROVIDER):
resp = self.http_manager.get(
self.config.url(ptmd_path),
headers=self.auth.build_ptmd_headers(),
operation="api",
)
playout = ZdfPlayout.from_api_response(resp.json())
return playout.manifest_url
def get_channel_manifest_headers(self, content_id: str, **kw: Any) -> Dict[str, str]:
# CDN headers, NOT the API headers the ABC default would return.
# Only effective with HEADERS_FROM_MANAGERS = True.
return self.config.stream_headers()
@@ -0,0 +1,198 @@
# 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):
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> -> DASH manifest URL
The two ``api-auth`` bearer values are the PUBLIC client tokens of the ZDF web
player (visible in any browser capture), not user credentials. ZDF rotates them
from time to time: override via the provider config dict
(``api_token`` / ``list_token`` / ``app_id``) instead of editing this file.
They 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-"
# --- DASH selection ----------------------------------------------------
# Only DASH is wanted (no HLS). A formitaet counts as DASH when its
# mimeType contains "dash" or its type contains "mpd".
DASH_MIME_MARKER = "dash"
DASH_TYPE_MARKER = "mpd"
# Preferred quality entries, best first ("auto" = the adaptive master).
DASH_QUALITY_ORDER = ("auto", "veryhigh", "high", "hd", "med", "low")
AUDIO_CLASS_PREFERRED = "main" # not "ad" (audio description)
AUDIO_LANGUAGE_PREFERRED = "deu"
# --- Public web-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 ------------------------------------------------
def _base_headers(self) -> dict:
return {
"User-Agent": self.user_agent,
"Accept-Language": ZdfDefaults.ACCEPT_LANGUAGE,
"Origin": ZdfDefaults.ORIGIN,
"Referer": ZdfDefaults.REFERER,
}
def list_headers(self) -> dict:
"""Persisted-query GET (channel list)."""
headers = self._base_headers()
headers["Accept"] = ZdfDefaults.ACCEPT_LIST
headers["Content-Type"] = "application/json"
headers["api-auth"] = f"Bearer {self.list_token}"
headers["zdf-app-id"] = self.app_id
return headers
def graphql_headers(self) -> dict:
"""VideoByCanonical POST."""
headers = self._base_headers()
headers["Accept"] = "*/*"
headers["Content-Type"] = "application/json"
headers["api-auth"] = f"Bearer {self.api_token}"
return headers
def ptmd_headers(self) -> dict:
"""PTMD GET."""
headers = self._base_headers()
headers["Accept"] = ZdfDefaults.ACCEPT_PTMD
headers["api-auth"] = f"Bearer {self.api_token}"
return headers
def stream_headers(self) -> dict:
"""
Headers for MPD / 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,
}
@@ -0,0 +1,201 @@
# streaming_providers/providers/zdf/models.py
"""
Provider-local data models and pure parsers. No business logic, no network.
Parsing is STRICT on purpose (KeyError on missing required fields); the manager
parses entry-by-entry and skips malformed entries, so one bad channel cannot
take the whole list down.
The channel list of ZDF contains MANY non-channels (test items, event streams,
scheduled shows); `is_channel_node` is the one place that decides what counts
as a channel.
"""
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Tuple
from ...base.models import Channel
from .constants import ZdfDefaults
# ----------------------------------------------------------------------
# GraphQL helpers
# ----------------------------------------------------------------------
def _raise_on_graphql_errors(payload: Any) -> Dict[str, Any]:
"""Return payload["data"] or raise ValueError (-> ServerError via transport_errors)."""
if not isinstance(payload, dict):
raise ValueError("GraphQL response is not a JSON object")
data = payload.get("data")
errors = payload.get("errors")
if errors and not data:
first = errors[0] if isinstance(errors, list) and errors else errors
message = first.get("message") if isinstance(first, dict) else str(first)
raise ValueError(f"GraphQL error: {message}")
if not isinstance(data, dict):
raise ValueError("GraphQL response has no data")
return data
def extract_video_nodes(payload: Any) -> List[Dict[str, Any]]:
"""data.videos.nodes of the getActiveLiveStreams response."""
data = _raise_on_graphql_errors(payload)
nodes = (data.get("videos") or {}).get("nodes")
if not isinstance(nodes, list):
raise ValueError("channel list response is missing data.videos.nodes")
return nodes
def resolve_ptmd_path(template: str) -> str:
"""'/tmd/2/{playerId}/live/ptmd/247onAir-203' -> '/tmd/2/ngplayer_2_5/live/ptmd/247onAir-203'."""
path = template.replace(ZdfDefaults.PLAYER_ID_PLACEHOLDER, ZdfDefaults.PLAYER_ID)
if not path.startswith("/tmd/"):
raise ValueError(f"unexpected ptmdTemplate: {template!r}")
return path
def ptmd_path_from_video(payload: Any) -> Optional[str]:
"""
ptmd path from a VideoByCanonical response.
None = the video does not exist or currently has no live media
(the manager turns that into NotFoundError).
raise = malformed payload.
"""
data = _raise_on_graphql_errors(payload)
video = data.get("videoByCanonical")
if not video:
return None
for media in (video.get("currentMedia") or {}).get("nodes") or []:
template = media.get("ptmdTemplate")
if template:
return resolve_ptmd_path(template)
return None
# ----------------------------------------------------------------------
# Channels
# ----------------------------------------------------------------------
def is_channel_node(node: Dict[str, Any]) -> bool:
"""
True for a real 24/7 channel of the getActiveLiveStreams list.
A channel is: currently LIVE, not hidden from the index (all "Testsammlung"
/ smoke-test items are), on air, and backed by a 24/7 ptmd id. Scheduled
shows (currentMediaType NOVIDEO) and event streams fail this on purpose.
"""
if not isinstance(node, dict):
return False
if node.get("currentMediaType") != "LIVE" or node.get("excludeFromIndex"):
return False
media = (node.get("currentMedia") or {}).get("nodes") or []
if not media or not isinstance(media[0], dict):
return False
first = media[0]
template = first.get("ptmdTemplate") or ""
return first.get("state") == "onair" and ZdfDefaults.LIVE247_MARKER in template
@dataclass
class ZdfChannel:
canonical: str # content_id: stable editorial id, e.g. "zdfinfo-live-beitrag-100"
name: str
ptmd_path: str # resolved, ready for config.url()
geo_location: Optional[str] = None
@classmethod
def from_api_response(cls, node: Dict[str, Any]) -> "ZdfChannel":
media = node["currentMedia"]["nodes"][0]
service = media.get("tvService") or node["teaser"]["title"]
name = ZdfDefaults.CHANNEL_NAME_OVERRIDES.get(service, service)
return cls(
canonical=node["canonical"],
name=name,
ptmd_path=resolve_ptmd_path(media["ptmdTemplate"]),
geo_location=media.get("geoLocation"),
)
def to_channel(self, provider_name: str) -> Channel:
# NOTE the factory parameter names: create_live_channel takes
# `channel_id`, create_vod_channel takes `content_id`.
channel = Channel.create_live_channel(
name=self.name, channel_id=self.canonical, provider=provider_name
)
channel.catchup_hours = 0 # set only when catchup is really offered
return channel
# ----------------------------------------------------------------------
# Playout (PTMD)
# ----------------------------------------------------------------------
def _attr(attributes: Dict[str, Any], name: str) -> Optional[str]:
entry = attributes.get(name)
return entry.get("value") if isinstance(entry, dict) else None
def _is_dash(formitaet: Dict[str, Any]) -> bool:
mime = (formitaet.get("mimeType") or "").lower()
kind = (formitaet.get("type") or "").lower()
return ZdfDefaults.DASH_MIME_MARKER in mime or ZdfDefaults.DASH_TYPE_MARKER in kind
def _rank(order: Tuple[str, ...], value: Optional[str]) -> int:
return order.index(value) if value in order else len(order)
def pick_dash_url(priority_list: Iterable[Dict[str, Any]]) -> Tuple[Optional[str], List[str]]:
"""
Choose ONE DASH manifest URL. Returns (url or None, offered formats).
Order of preference: earlier priorityList item, then quality
(DASH_QUALITY_ORDER, "auto" first), then main-class German audio track,
then https. HLS and everything else is ignored.
"""
offered: List[str] = []
best: Optional[Tuple[Tuple[int, int, int, int], str]] = None
for p_idx, item in enumerate(priority_list or []):
for formitaet in item.get("formitaeten") or []:
offered.append(formitaet.get("mimeType") or formitaet.get("type") or "?")
if not _is_dash(formitaet):
continue
for quality in formitaet.get("qualities") or []:
q_rank = _rank(ZdfDefaults.DASH_QUALITY_ORDER, quality.get("quality"))
for track in (quality.get("audio") or {}).get("tracks") or []:
uri = track.get("uri")
if not uri:
continue
t_rank = 0 if (
track.get("class") == ZdfDefaults.AUDIO_CLASS_PREFERRED
and track.get("language") == ZdfDefaults.AUDIO_LANGUAGE_PREFERRED
) else 1
key = (p_idx, q_rank, t_rank, 0 if uri.startswith("https://") else 1)
if best is None or key < best[0]:
best = (key, uri)
return (best[1] if best else None), offered
@dataclass
class ZdfPlayout:
"""Response of the ptmd endpoint, reduced to what playback needs."""
manifest_url: str
ptmd_id: str
station: Optional[str] = None
@classmethod
def from_api_response(cls, data: Dict[str, Any]) -> "ZdfPlayout":
if not isinstance(data, dict):
raise ValueError("ptmd response is not a JSON object")
attributes = data.get("attributes") or {}
encryption = _attr(attributes, "encryption")
if encryption not in (None, "none"):
raise ValueError(f"encrypted stream ({encryption}) is not supported")
url, offered = pick_dash_url(data.get("priorityList") or [])
if not url:
raise ValueError(
f"ptmd response offers no DASH stream (offered: {sorted(set(offered))})"
)
return cls(
manifest_url=url,
ptmd_id=data.get("id") or "",
station=_attr(attributes, "station"),
)
@@ -0,0 +1,77 @@
# streaming_providers/providers/zdf/provider.py
"""
ZDF provider (live TV of ZDF, ZDFneo, ZDFinfo, 3sat, phoenix, KiKA, ARTE).
Capabilities: channels only. Clear DASH streams, no account, no DRM, no EPG
(the generic XMLTV path answers EPG), no catchup.
Known limitations:
* Streams are geo-limited to Germany; a ProxyConfig only affects the
Python-side API calls. The manifest and segment fetches are made by
inputstream.adaptive inside Kodi and do not use it.
* The api-auth client tokens are public web-player tokens and may be rotated
by ZDF (config keys api_token / list_token / app_id).
"""
from typing import ClassVar, Dict, List, Optional, Tuple
from ...base.managed_provider import ManagedProvider
from ...base.models.proxy_models import ProxyConfig
from .auth import ZdfAuth
from .channel_manager import ZdfChannelManager
from .constants import ZdfConfig, ZdfDefaults
__all__ = ["ZdfProvider"]
class ZdfProvider(ManagedProvider):
PROVIDER_LABEL: ClassVar[str] = ZdfDefaults.PROVIDER_LABEL
PROVIDER_LOGO: ClassVar[str] = ZdfDefaults.PROVIDER_LOGO
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = [] # no user authentication
SUPPORTED_COUNTRIES: ClassVar[List[str]] = list(ZdfDefaults.SUPPORTED_COUNTRIES)
# Decision 1 (§11): clear streams, no DRM at all -> no folded DRM, no _build_drm().
DRM_IN_MANAGERS: ClassVar[bool] = False
# Decision 2 (§10): the manager's CDN headers (UA/Origin/Referer, no tokens)
# are what the player must send for MPD and segments.
HEADERS_FROM_MANAGERS: ClassVar[bool] = True
@property
def provider_name(self) -> str:
return ZdfDefaults.PROVIDER_NAME
def __init__(
self,
country: str = "DE",
config: Optional[Dict] = None,
proxy_config: Optional[ProxyConfig] = None,
):
super().__init__(country=country)
self.country = self.country.lower()
if self.country.upper() not in ZdfDefaults.SUPPORTED_COUNTRIES:
raise NotImplementedError(f"{ZdfDefaults.PROVIDER_LABEL}: unsupported country {country!r}")
# 1. ONE config, shared with auth and the manager
self.provider_config = ZdfConfig(config or {})
# 2. HTTP
self.http_manager = self._setup_http_manager(
provider_name=ZdfDefaults.PROVIDER_NAME,
proxy_config=proxy_config,
user_agent=self.provider_config.user_agent,
timeout=self.provider_config.timeout,
)
# 3. auth (static public tokens, nothing lazy to do)
self.auth = ZdfAuth(config=self.provider_config)
# 4. caches (provider-owned, borrowed by the manager)
self._ptmd_cache: Dict[str, Tuple[str, float]] = {}
# 5. managers -- must be the last line
self._init_managers()
def _build_channels(self) -> ZdfChannelManager:
return ZdfChannelManager(
http_manager=self.http_manager,
auth=self.auth,
country=self.country,
config=self.provider_config,
ptmd_cache=self._ptmd_cache,
)