Files
script.service.ultimate/lib/streaming_providers/providers/zdf/models.py
T
2026-10-17 12:43:25 +02:00

248 lines
9.6 KiB
Python

# 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.
"""
import json
import re
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, Iterable, List, Optional, Tuple
from ...base.models import Channel
from .constants import ZdfDefaults
# ----------------------------------------------------------------------
# Web-client tokens (parsed from the zdf.de page, see auth.py)
# ----------------------------------------------------------------------
_FLIGHT_CHUNK = re.compile(r'self\.__next_f\.push\(\[1,\s*"((?:[^"\\]|\\.)*)"\]\)')
def _parse_expiry(value: str) -> float:
"""ISO-8601 with offset ('2026-10-18T05:21:01+02:00', also 'Z') -> unix seconds."""
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
def parse_flight_tokens(html: str) -> Dict[str, Tuple[str, float]]:
"""
{kind: (token, expires_at_unix)} from the zdf.de start page.
Joins every ``self.__next_f.push([1,"..."])`` chunk (JSON-string
unescaped) and reads the ``videoToken`` / ``appToken`` objects out of the
result. Kinds that are missing or malformed are simply absent; the caller
decides what an incomplete result means.
"""
chunks: List[str] = []
for raw in _FLIGHT_CHUNK.findall(html or ""):
try:
chunks.append(json.loads('"' + raw + '"'))
except ValueError:
continue
flight = "".join(chunks)
tokens: Dict[str, Tuple[str, float]] = {}
for name, kind in ZdfDefaults.FLIGHT_TOKEN_NAMES.items():
match = re.search(
r'"%s":\{"apiToken":"([^"]+)","expiresAt":"([^"]+)"\}' % re.escape(name),
flight,
)
if not match:
continue
try:
tokens[kind] = (match.group(1), _parse_expiry(match.group(2)))
except ValueError:
continue
return tokens
# ----------------------------------------------------------------------
# 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_hls(formitaet: Dict[str, Any]) -> bool:
mime = (formitaet.get("mimeType") or "").lower()
kind = (formitaet.get("type") or "").lower()
return ZdfDefaults.HLS_MIME_MARKER in mime or ZdfDefaults.HLS_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_hls_url(priority_list: Iterable[Dict[str, Any]]) -> Tuple[Optional[str], List[str]]:
"""
Choose ONE HLS master playlist URL. Returns (url or None, offered formats).
Order of preference: earlier priorityList item, then quality
(HLS_QUALITY_ORDER, "auto" first), then main-class German audio track,
then https. DASH 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_hls(formitaet):
continue
for quality in formitaet.get("qualities") or []:
q_rank = _rank(ZdfDefaults.HLS_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_hls_url(data.get("priorityList") or [])
if not url:
raise ValueError(
f"ptmd response offers no HLS stream (offered: {sorted(set(offered))})"
)
return cls(
manifest_url=url,
ptmd_id=data.get("id") or "",
station=_attr(attributes, "station"),
)