mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-17 13:22:29 +02:00
add zdf
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user