diff --git a/lib/streaming_providers/providers/zdf/__init__.py b/lib/streaming_providers/providers/zdf/__init__.py new file mode 100644 index 0000000..c02b6df --- /dev/null +++ b/lib/streaming_providers/providers/zdf/__init__.py @@ -0,0 +1,4 @@ +# streaming_providers/providers/zdf/__init__.py +from .provider import ZdfProvider + +__all__ = ["ZdfProvider"] diff --git a/lib/streaming_providers/providers/zdf/auth.py b/lib/streaming_providers/providers/zdf/auth.py new file mode 100644 index 0000000..a3a798f --- /dev/null +++ b/lib/streaming_providers/providers/zdf/auth.py @@ -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 diff --git a/lib/streaming_providers/providers/zdf/channel_manager.py b/lib/streaming_providers/providers/zdf/channel_manager.py new file mode 100644 index 0000000..d23936d --- /dev/null +++ b/lib/streaming_providers/providers/zdf/channel_manager.py @@ -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() diff --git a/lib/streaming_providers/providers/zdf/constants.py b/lib/streaming_providers/providers/zdf/constants.py new file mode 100644 index 0000000..25ce403 --- /dev/null +++ b/lib/streaming_providers/providers/zdf/constants.py @@ -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//live/ptmd/ -> 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-". 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, + } diff --git a/lib/streaming_providers/providers/zdf/models.py b/lib/streaming_providers/providers/zdf/models.py new file mode 100644 index 0000000..383a0d6 --- /dev/null +++ b/lib/streaming_providers/providers/zdf/models.py @@ -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"), + ) diff --git a/lib/streaming_providers/providers/zdf/provider.py b/lib/streaming_providers/providers/zdf/provider.py new file mode 100644 index 0000000..fbfedc4 --- /dev/null +++ b/lib/streaming_providers/providers/zdf/provider.py @@ -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, + )