Refactor Magenta2

This commit is contained in:
Nirvana
2026-05-27 20:12:29 +02:00
parent 1c56debfaf
commit a6078e98e2
4 changed files with 1617 additions and 1438 deletions
@@ -0,0 +1,320 @@
# streaming_providers/providers/magenta2/auth_bridge.py
# -*- coding: utf-8 -*-
"""
AuthBridge — token-consumer layer between Magenta2Provider and Magenta2Authenticator.
Responsibilities:
- In-memory persona-token cache (TTL-based, 60 s safety margin)
- tvhubs-scoped Bearer token retrieval and refresh (via TokenFlowManager)
- Platform-aware auth-header construction for VOD and nPVR endpoints
- Auth-state and readiness inspection (moved from provider.py)
What this class does NOT do:
- Token acquisition / OAuth flows → Magenta2Authenticator / TokenFlowManager
- Endpoint discovery → DiscoveryService / EndpointManager
- Any HTTP requests of its own
Usage (from Magenta2Provider.__init__)::
self._auth = AuthBridge(
authenticator=self.authenticator,
provider_name=self.provider_name,
country=self.country,
platform=self.platform,
platform_config=self.platform_config,
provider_config=self.provider_config,
session_id=self.session_id,
serial_number=self.serial_number,
generate_call_id=self._generate_call_id,
)
"""
import time
from typing import Any, Dict, Optional, Tuple
from ...base.models.auth import AuthState
from ...base.utils.logger import logger
from .playback_manager import PlaybackManager
from .token_flow_manager import PersonaResult
class AuthBridge:
"""
Thin token-consumer and caching façade.
The bridge holds a reference to the authenticator but never calls into
its private internals — all refresh work is delegated to the public
TokenFlowManager API.
"""
def __init__(
self,
authenticator: Any,
provider_name: str,
country: str,
platform: str,
platform_config: Dict[str, Any],
provider_config: Any,
session_id: str,
serial_number: str,
generate_call_id, # callable() -> str
) -> None:
self._authenticator = authenticator
self._provider_name = provider_name
self._country = country
self._platform = platform
self._platform_config = platform_config
self._provider_config = provider_config
self._session_id = session_id
self._serial_number = serial_number
self._generate_call_id = generate_call_id
self._persona_cache: Optional[PersonaResult] = None
# ------------------------------------------------------------------ #
# provider_config setter — updated after configuration discovery #
# ------------------------------------------------------------------ #
def update_provider_config(self, provider_config: Any) -> None:
"""Called by the provider after configuration discovery completes."""
self._provider_config = provider_config
# ------------------------------------------------------------------ #
# Persona token #
# ------------------------------------------------------------------ #
def get_persona_token(self, force_refresh: bool = False) -> str:
"""
Return a valid persona token, using an in-memory TTL cache.
Raises:
RuntimeError: If TokenFlowManager is not available.
Exception: If the underlying token flow fails.
"""
if not force_refresh and self._persona_cache and self._persona_cache.success:
expires_at: float = self._persona_cache.expires_at
if time.time() < (expires_at - 60):
logger.debug(
f"Using cached persona token (expires at {time.ctime(expires_at)})"
)
return self._persona_cache.persona_token
self._persona_cache = None
logger.debug("In-memory persona cache expired")
tfm = self._authenticator.token_flow_manager
if tfm is None:
raise RuntimeError("TokenFlowManager is not available on the authenticator")
persona_result: PersonaResult = tfm.get_persona_token(force_refresh=force_refresh)
if not persona_result.success:
raise Exception(f"Failed to get persona token: {persona_result.error}")
self._persona_cache = persona_result
logger.debug(
f"Cached persona token (expires at {time.ctime(persona_result.expires_at)})"
)
return persona_result.persona_token
def ensure_authenticated(self) -> str:
"""Return a valid persona token (lazy, no forced refresh). Callable as a callback."""
return self.get_persona_token(force_refresh=False)
def clear_persona_cache(self) -> None:
"""Discard the in-memory persona token cache."""
self._persona_cache = None
logger.debug("Cleared in-memory persona cache")
# ------------------------------------------------------------------ #
# tvhubs Bearer token #
# ------------------------------------------------------------------ #
def get_tvhubs_bearer(self) -> Optional[str]:
"""
Return a valid tvhubs-scoped access token for use as a Bearer token.
Delegates to TokenFlowManager.get_tvhubs_token() which owns the full
lifecycle: cache read → TTL check → refresh → full re-auth chain fallback.
Returns None if the token is unavailable after all attempts.
"""
tfm = self._authenticator.token_flow_manager
if tfm is None:
return None
try:
return tfm.get_tvhubs_token()
except Exception as exc:
logger.debug(f"Could not obtain tvhubs token: {exc}")
return None
# ------------------------------------------------------------------ #
# Auth-header builders #
# ------------------------------------------------------------------ #
def vod_auth_headers(self) -> Dict[str, str]:
"""
Build auth headers for VOD endpoints, platform-aware.
Strategy:
1. Prefer tvhubs-scoped Bearer token.
2. Fall back to persona JWT extracted from the composed persona token.
3. Last resort: Basic + raw persona token.
"""
persona_token = self.ensure_authenticated()
tvhubs_token = self.get_tvhubs_bearer()
if tvhubs_token:
auth_value = f"Bearer {tvhubs_token}"
logger.debug("VOD auth: using tvhubs token as Bearer")
else:
persona_jwt = PlaybackManager.extract_persona_jwt_from_token(persona_token)
auth_value = (
f"Bearer {persona_jwt}" if persona_jwt else f"Basic {persona_token}"
)
logger.debug("VOD auth: tvhubs token unavailable, falling back to persona_jwt")
client_model: str = (
self._provider_config.bootstrap.client_model
if self._provider_config and self._provider_config.bootstrap
else f"ftv-{self._platform}"
) or f"ftv-{self._platform}"
is_web = client_model == "ftv-web"
if is_web:
return {
"Authorization": auth_value,
"x-mpx-authorization": f"Basic {persona_token}",
"x-dt-session-id": self._session_id,
"x-dt-call-id": self._generate_call_id(),
"origin": "https://www.magenta.tv",
"referer": "https://www.magenta.tv/",
"user-agent": self._platform_config["user_agent"],
"accept": "*/*",
"accept-encoding": "gzip, deflate, br, zstd",
"accept-language": "de-DE,de;q=0.9",
"x-permissionflagpersonalizeduireco": "false",
}
else:
return {
"Authorization": auth_value,
"x-mpx-authorization": f"Basic {persona_token}",
"x-stbserialnumber": self._serial_number,
"dt-session-id": self._session_id,
"dt-call-id": self._generate_call_id(),
"user-agent": self._platform_config["user_agent"],
"accept-encoding": "gzip",
}
def pvr_auth_headers(self) -> Dict[str, str]:
"""Build auth headers for nPVR recording endpoints."""
persona_token = self.ensure_authenticated()
return {
"Authorization": f"Basic {persona_token}",
"User-Agent": self._platform_config["user_agent"],
"Accept-Encoding": "gzip",
"CID": f"{self._session_id}::{self._generate_call_id()}",
}
# ------------------------------------------------------------------ #
# Auth-state introspection (moved from provider.py) #
# ------------------------------------------------------------------ #
def calculate_auth_state(self, context: Any) -> AuthState:
"""Derive AuthState from the stored persona token."""
persona_token = context.get_token(self._provider_name, "persona", self._country)
if not persona_token:
logger.debug("No persona token found")
return AuthState.NOT_AUTHENTICATED
if not isinstance(persona_token, dict) or "persona_token" not in persona_token:
logger.warning("Invalid persona token structure")
return AuthState.NOT_AUTHENTICATED
if "expires_at" in persona_token:
current_time = time.time()
expires_at: float = persona_token["expires_at"]
if current_time >= (expires_at - 300):
logger.debug(
f"Persona token expired (expires_at: {expires_at}, now: {current_time})"
)
return AuthState.EXPIRED
logger.debug("Persona token is valid")
return AuthState.AUTHENTICATED
def calculate_readiness(self, context: Any) -> Tuple[bool, str]:
"""Return (is_ready, reason) based on token availability and expiry."""
persona_token = context.get_token(self._provider_name, "persona", self._country)
if not persona_token:
return False, "No persona token available"
if not isinstance(persona_token, dict) or "persona_token" not in persona_token:
return False, "Invalid persona token structure"
if "expires_at" in persona_token:
current_time = time.time()
expires_at: float = persona_token["expires_at"]
if current_time >= (expires_at - 300):
yo_token = context.get_token(
self._provider_name, "yo_digital", self._country
)
if yo_token and "refresh_token" in yo_token:
if (
"refresh_token_expires_in" in yo_token
and "refresh_token_issued_at" in yo_token
):
refresh_expires_at: float = (
yo_token["refresh_token_issued_at"]
+ yo_token["refresh_token_expires_in"]
)
if current_time < (refresh_expires_at - 300):
return (
True,
"Persona token expired but can be refreshed via yo_digital",
)
return False, f"Persona token expired (expired at {time.ctime(expires_at)})"
return True, "Has valid persona token"
def get_auth_details(self, token_scopes: list, context: Any) -> Dict[str, Any]:
"""Return per-scope token status for all provider token scopes."""
details: Dict[str, Any] = {}
for scope in token_scopes:
token = context.get_token(self._provider_name, scope, self._country)
if not token:
details[scope] = {"available": False}
continue
scope_info: Dict[str, Any] = {"available": True}
if scope == "persona":
if "expires_at" in token:
current_time = time.time()
expires_at: float = token["expires_at"]
scope_info["expires_at"] = expires_at
scope_info["is_expired"] = current_time >= expires_at
scope_info["time_remaining"] = int(max(0, expires_at - current_time))
if "composed_at" in token:
scope_info["composed_at"] = token["composed_at"]
elif scope == "yo_digital":
if "access_token_expires_in" in token and "access_token_issued_at" in token:
current_time = time.time()
at_expires: float = (
token["access_token_issued_at"] + token["access_token_expires_in"]
)
scope_info["access_token_expires_at"] = at_expires
scope_info["access_token_is_expired"] = current_time >= at_expires
if "refresh_token_expires_in" in token and "refresh_token_issued_at" in token:
current_time = time.time()
rt_expires: float = (
token["refresh_token_issued_at"] + token["refresh_token_expires_in"]
)
scope_info["refresh_token_expires_at"] = rt_expires
scope_info["refresh_token_is_expired"] = current_time >= rt_expires
scope_info["has_refresh_token"] = True
else:
if "expires_in" in token and "issued_at" in token:
current_time = time.time()
std_expires: float = token["issued_at"] + token["expires_in"]
scope_info["expires_at"] = std_expires
scope_info["is_expired"] = current_time >= std_expires
details[scope] = scope_info
return details
@@ -0,0 +1,648 @@
# streaming_providers/providers/magenta2/channel_manager.py
# -*- coding: utf-8 -*-
"""
Manages channel discovery, metadata enrichment, entitlement, and streaming-data
population for the Magenta2 provider.
Responsibilities
----------------
- Fetch and cache the unauthenticated channel-stations feed (_fetch_station_metadata)
- Build StreamingChannel objects from theplatform feed entries
- Fetch the entitled-channels feed and merge it with station metadata (get_channels)
- Request entitlement tokens and playlist data per channel
- Populate streaming data (manifest URL, DRM) for a set of channels
The live-manifest / live-pid caches live here because they are produced by
get_channels() and consumed by PlaybackManager via the provider's cache
attributes (_live_manifest_cache, _live_pid_cache).
"""
import json
import time
from typing import Callable, Dict, List, Optional
from ...base.models import StreamingChannel
from ...base.utils.logger import logger
from .constants import (
CONTENT_TYPE_LIVE,
DEFAULT_EPG_WINDOW_HOURS,
DEFAULT_MAX_RETRIES,
DEFAULT_REQUEST_TIMEOUT,
DISTRIBUTION_PACKAGE_NAMES,
DRM_SYSTEM_WIDEVINE,
ERROR_CODES,
MODE_LIVE,
QUALITY_RANK,
)
from .endpoint_manager import EndpointManager
from .config_models import ProviderConfig
from .models import Magenta2Channel, Magenta2PlaybackRestrictedException
from ..lib_theplatform import (
TheplatformChannel,
fetch_distribution_rights,
fetch_entitled_channels_feed,
)
class ChannelManager:
"""
Handles all channel-related operations for Magenta2.
Parameters
----------
http_manager:
Shared HTTP manager (created by the provider).
provider_name:
Provider name string used when building StreamingChannel objects.
country:
Two-letter country code.
platform_config:
Platform-specific dict from MAGENTA2_PLATFORMS (user_agent, etc.).
session_id:
Session UUID shared with the provider instance.
serial_number:
Device serial UUID shared with the provider instance.
endpoint_manager:
Populated EndpointManager after discovery.
provider_config:
ProviderConfig after discovery.
auth_callback:
Callable[[], str] — returns a valid persona token (Basic-auth value).
Provided by the provider as ``self._ensure_authenticated``.
build_scaled_image_url_callback:
Callable[[str], Optional[str]] — scales a logo URL.
Provided by the provider.
"""
def __init__(
self,
http_manager,
provider_name: str,
country: str,
platform_config: Dict,
session_id: str,
serial_number: str,
endpoint_manager: Optional[EndpointManager],
provider_config: Optional[ProviderConfig],
auth_callback: Callable[[], str],
build_scaled_image_url_callback: Callable[[str], Optional[str]],
):
self._http = http_manager
self._provider_name = provider_name
self._country = country
self._platform_config = platform_config
self._session_id = session_id
self._serial_number = serial_number
self._endpoint_manager = endpoint_manager
self._provider_config = provider_config
self._ensure_authenticated = auth_callback
self._build_scaled_image_url = build_scaled_image_url_callback
# Populated on first get_channels() call; returned directly on subsequent calls.
self._cached_channels: Optional[List[StreamingChannel]] = None
# release_pid → mpd_url, keyed by release_pid (= channel_id after get_channels).
self._live_manifest_cache: Dict[str, str] = {}
# release_pid → release_pid (used as a fast membership test by PlaybackManager).
self._live_pid_cache: Dict[str, str] = {}
# Populated eagerly at provider init (unauthenticated call).
self.station_metadata: Dict[str, Dict] = self._fetch_station_metadata()
# ------------------------------------------------------------------ #
# Public API #
# ------------------------------------------------------------------ #
def get_channels(
self,
time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS,
fetch_manifests: bool = False,
populate_streaming: bool = True,
prefer_highest_quality: bool = True,
**kwargs,
) -> List[StreamingChannel]:
"""
Fetch available channels via the entitled-channels flow.
Uses lib_theplatform to:
1. Call getApplicableDistributionRights (license_service_url from manifest).
2. Fetch the entitled-channels feed filtered by those rights.
3. Merge with station metadata pre-fetched at init time (unauthenticated).
4. Convert each TheplatformChannel to a StreamingChannel.
Results are cached after the first successful call; subsequent calls
return from cache without hitting the network.
"""
if self._cached_channels:
logger.debug("get_channels: returning from cache (no network calls)")
return list(self._cached_channels)
try:
import uuid
cid = f"{self._session_id}::{str(uuid.uuid4())}"
user_agent = self._platform_config["user_agent"]
# ── Step 1: distribution rights ──────────────────────────────────
rights_url = (
self._provider_config.manifest.mpx.license_service_url
if self._provider_config and self._provider_config.manifest
else None
)
if not rights_url:
raise RuntimeError(
"No license_service_url available configuration discovery may have failed"
)
persona_token = self._ensure_authenticated()
auth_headers = {
"Authorization": f"Basic {persona_token}",
"Origin": "https://www.magenta.tv",
"Referer": "https://www.magenta.tv/",
}
distribution_rights = fetch_distribution_rights(
http_manager=self._http,
rights_url=rights_url,
cid=cid,
user_agent=user_agent,
timeout=DEFAULT_REQUEST_TIMEOUT,
extra_headers=auth_headers,
)
def _dist_uri_to_int(uri) -> Optional[int]:
try:
return int(str(uri).rstrip("/").rsplit("/", 1)[-1])
except (ValueError, AttributeError):
return None
package_names = [
DISTRIBUTION_PACKAGE_NAMES.get(numeric_id, f"Unknown package ({dist_id})")
for dist_id in distribution_rights
if (numeric_id := _dist_uri_to_int(dist_id)) is not None
]
logger.info(f"Active subscription packages ({len(package_names)}):")
for pkg in package_names:
logger.info(f" · {pkg}")
# ── Step 2: entitled-channels feed ───────────────────────────────
feed_url = (
self._endpoint_manager.get_endpoint("mpx_feed_entitledChannelsFeed")
if self._endpoint_manager
else None
) or "https://feed.entertainment.tv.theplatform.eu/f/mdeprod/mdeprod-entitled-channels"
tp_channels: List[TheplatformChannel] = fetch_entitled_channels_feed(
http_manager=self._http,
feed_url=feed_url,
distribution_rights=distribution_rights,
cid=cid,
user_agent=user_agent,
timeout=DEFAULT_REQUEST_TIMEOUT,
)
# ── Step 3: merge with station metadata (pre-fetched at init) ────
channels: List[StreamingChannel] = []
for tp_ch in tp_channels:
try:
meta = self.station_metadata.get(tp_ch.station_id, {})
name = meta.get("title") or tp_ch.station_id
logo_url = meta.get("logo_url")
quality = meta.get("quality")
channel_number = (
meta["channel_number"]
if meta.get("channel_number") is not None
else tp_ch.channel_number
)
magenta2_channel = Magenta2Channel(
name=name,
channel_id=tp_ch.release_pid,
logo_url=logo_url,
mode=MODE_LIVE,
content_type=CONTENT_TYPE_LIVE,
country=self._country,
raw_data=tp_ch.extra,
)
streaming_channel = magenta2_channel.to_streaming_channel(
provider_name=self._provider_name
)
streaming_channel.channel_number = channel_number
streaming_channel.quality = quality
streaming_channel.manifest = tp_ch.mpd_url
if tp_ch.hls_url:
streaming_channel.hls_url = tp_ch.hls_url
self._live_manifest_cache[tp_ch.release_pid] = tp_ch.mpd_url
self._live_pid_cache[tp_ch.release_pid] = tp_ch.release_pid
channels.append(streaming_channel)
except Exception as exc:
logger.warning(f"get_channels: skipping channel {tp_ch.station_id}: {exc}")
logger.info(
f"Successfully fetched {len(channels)} entitled channels "
f"for country {self._country} "
f"({len(self.station_metadata)} stations with metadata)"
)
channels.sort(key=lambda ch: (ch.channel_number is None, ch.channel_number or 0))
self._cached_channels = channels
return channels
except Exception as e:
raise Exception(f"Error fetching channels from Magenta2 API: {e}")
def get_entitlement_token(
self, content_id: str, content_type: str = CONTENT_TYPE_LIVE
) -> str:
"""
Request an entitlement token for *content_id* using the persona token
(Basic auth).
"""
self._ensure_authenticated()
headers = self._get_api_headers(require_auth=True)
payload = {"content_id": content_id, "content_type": content_type}
url = (
self._endpoint_manager.get_endpoint("entitlement")
if self._endpoint_manager
else "https://entitlement.p7s1.io/api/user/entitlement-token"
)
try:
logger.debug(f"Requesting entitlement token for: {content_id}")
response = self._http.post(
url,
operation="auth",
headers=headers,
json_data=payload,
timeout=DEFAULT_REQUEST_TIMEOUT,
)
if response.status_code == 400:
try:
error_data = response.json()
error_list = error_data if isinstance(error_data, list) else [error_data]
if error_list:
error = error_list[0]
code = error.get("code", error.get("errorCode", "UNKNOWN"))
msg = error.get("msg", error.get("message", "No error message provided"))
if code == ERROR_CODES["PLAYBACK_RESTRICTED"]:
raise Magenta2PlaybackRestrictedException(
f"Playback restricted for {content_id}: {msg}"
)
else:
raise Exception(f"Entitlement error for {content_id} ({code}): {msg}")
except (json.JSONDecodeError, KeyError, IndexError) as e:
raise Exception(
f"Bad response for {content_id} (400), failed to parse error: {e}"
)
response.raise_for_status()
data = response.json()
if "entitlement_token" in data:
return data["entitlement_token"]
elif "entitlementToken" in data:
return data["entitlementToken"]
elif "token" in data:
return data["token"]
else:
raise KeyError("No entitlement token found in response")
except Magenta2PlaybackRestrictedException:
raise
except KeyError as e:
logger.error(f"No entitlement token in response for {content_id}: {e}")
raise Exception(f"No entitlement token in response for {content_id}: {e}")
except Exception as e:
logger.error(f"Error getting entitlement token for {content_id}: {e}")
raise Exception(f"Error getting entitlement token for {content_id}: {e}")
def get_channel_playlist(self, channel_id: str, entitlement_token: str) -> Dict:
"""Fetch playlist data (manifest URL, licence URL, format) for a channel."""
if self._endpoint_manager and self._endpoint_manager.has_endpoint("channel_playlist"):
url = self._endpoint_manager.get_endpoint("channel_playlist").format(
channel_id=channel_id
)
else:
url = f"https://api.magentatv.de/v1/channel/{channel_id}/playlist"
headers = {
"Authorization": f"Bearer {entitlement_token}",
"User-Agent": self._platform_config["user_agent"],
"Accept": "application/json",
}
try:
response = self._http.get(
url,
operation="manifest",
headers=headers,
timeout=DEFAULT_REQUEST_TIMEOUT,
)
response.raise_for_status()
return response.json()
except Exception as e:
raise Exception(f"Error getting playlist for {channel_id}: {e}")
def populate_streaming_data(
self,
channels: List[StreamingChannel],
max_retries: int = DEFAULT_MAX_RETRIES,
) -> List[StreamingChannel]:
"""
Populate manifest URL, DRM config, and streaming format for each channel
by fetching an entitlement token and playlist.
Channels that are restricted or repeatedly fail are silently dropped from
the returned list.
"""
self._ensure_authenticated()
successful_channels = []
for channel in channels:
retries = 0
success = False
is_restricted = False
while retries < max_retries and not success and not is_restricted:
try:
logger.debug(
f"Getting entitlement token for: {channel.name} (attempt {retries + 1})"
)
entitlement_token = self.get_entitlement_token(
content_id=channel.channel_id, content_type=channel.content_type
)
logger.debug(f"Getting playlist data for: {channel.name}")
playlist_data = self.get_channel_playlist(
channel.channel_id, entitlement_token
)
manifest_url = playlist_data.get(
"manifestUrl", playlist_data.get("manifest")
)
license_url = playlist_data.get(
"licenseUrl", playlist_data.get("license")
)
certificate_url = playlist_data.get(
"certificateUrl", playlist_data.get("certificate")
)
streaming_format = playlist_data.get(
"streamingFormat", playlist_data.get("format", "dash")
)
if manifest_url:
channel.manifest = manifest_url
channel.cdm_type = DRM_SYSTEM_WIDEVINE
channel.cdm = f"pid={channel.channel_id}"
channel.license_url = license_url
channel.certificate_url = certificate_url
channel.streaming_format = streaming_format
logger.info(f"Streaming data populated for: {channel.name}")
successful_channels.append(channel)
success = True
else:
raise Exception("No manifest URL in response")
except Magenta2PlaybackRestrictedException as e:
logger.warning(f"Playback restricted for {channel.name}: {e}")
is_restricted = True
except Exception as e:
retries += 1
if retries < max_retries:
logger.debug(f"Retry {retries}/{max_retries} for {channel.name}: {e}")
time.sleep(1)
else:
logger.error(f"Failed to get streaming data for {channel.name}: {e}")
logger.info(
f"Streaming data population complete: "
f"{len(successful_channels)} successful, "
f"{len(channels) - len(successful_channels)} failed/restricted, "
f"{len(channels)} total"
)
return successful_channels
def invalidate_cache(self) -> None:
"""Clear the in-memory channel and live-manifest caches."""
self._cached_channels = None
self._live_manifest_cache.clear()
self._live_pid_cache.clear()
logger.debug("ChannelManager: caches cleared")
# ------------------------------------------------------------------ #
# Internal helpers #
# ------------------------------------------------------------------ #
def _fetch_station_metadata(self) -> Dict[str, Dict]:
"""
Fetch the unauthenticated channel-stations feed and return a lookup map
keyed by the theplatform Station URI.
The URI is the key of the ``stations`` dict in each feed entry, e.g.
``http://data.entertainment.tv.theplatform.eu/…/Station/265808936224``.
This matches ``listings[0].stationId`` in the entitled-channels feed,
which is what ``TheplatformChannel.station_id`` contains after parsing.
Note: ``era$mediaPids["urn:theplatform:tv:location:any"]`` is a short
opaque PID used for other purposes — it is NOT the mapping key.
Each value dict contains:
title display name (" - Main" suffix stripped)
logo_url scaled logo URL or None
quality "HD", "SD", etc.
channel_number display channel number or None
"""
metadata: Dict[str, Dict] = {}
try:
url = None
if self._endpoint_manager:
url = (
self._endpoint_manager.get_endpoint("channel_stations")
or self._endpoint_manager.get_endpoint("channel_list")
)
url = (
url
or "https://feed.entertainment.tv.theplatform.eu/f/mdeprod/mdeprod-channel-stations-main"
)
url += "?lang=short-de&sort=dt%24displayChannelNumber&range=1-1000"
headers = self._get_api_headers(require_auth=False)
response = self._http.get(
url, operation="api", headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT
)
response.raise_for_status()
data = response.json()
for entry in data.get("entries", []):
try:
stations = entry.get("stations", {})
if not stations:
continue
station_uri = next(iter(stations.keys()))
station_info = stations[station_uri]
title = (
station_info.get("title") or entry.get("title", "")
).replace(" - Main", "")
quality = station_info.get("dt$quality", "SD")
logo_url = None
thumbnails = station_info.get("thumbnails", {})
for logo_type in ["stationLogo", "stationLogoColored"]:
if logo_type in thumbnails:
original_url = thumbnails[logo_type].get("url")
if original_url:
logo_url = self._build_scaled_image_url(original_url)
break
channel_number = entry.get("dt$displayChannelNumber")
existing = metadata.get(station_uri)
if not existing or QUALITY_RANK.get(quality, 1) > QUALITY_RANK.get(
existing["quality"], 1
):
metadata[station_uri] = {
"title": title,
"logo_url": logo_url,
"quality": quality,
"channel_number": channel_number,
}
except Exception as exc:
logger.debug(f"_fetch_station_metadata: skipping entry: {exc}")
logger.debug(
f"_fetch_station_metadata: built metadata for {len(metadata)} stations"
)
except Exception as exc:
logger.warning(
f"_fetch_station_metadata: failed, channels will have no names: {exc}"
)
return metadata
@staticmethod
def _extract_channel_id_from_entry(entry: Dict) -> Optional[str]:
"""Extract the correct channel ID from ``era$mediaPids``."""
try:
stations = entry.get("stations", {})
if not stations:
return None
station_id = next(iter(stations.keys()))
station_info = stations[station_id]
era_media_pids = station_info.get("era$mediaPids", {})
channel_id = era_media_pids.get("urn:theplatform:tv:location:any")
if channel_id:
logger.debug(f"Extracted channel ID from era$mediaPids: {channel_id}")
return channel_id
fallback_id = entry.get("guid")
if fallback_id:
logger.warning(f"Using fallback channel ID from guid: {fallback_id}")
return fallback_id
logger.warning("No channel ID found in entry")
return None
except Exception as e:
logger.warning(f"Error extracting channel ID from entry: {e}")
return None
def _create_channel_from_entry(
self, entry: Dict, station_info: Dict, display_number
) -> Optional[StreamingChannel]:
"""Build a StreamingChannel from a raw feed entry."""
try:
title = station_info.get("title") or entry.get("title", "Unknown Channel")
title = title.replace(" - Main", "")
channel_id = self._extract_channel_id_from_entry(entry)
if not channel_id:
return None
logo_url = None
thumbnails = station_info.get("thumbnails", {})
for logo_type in ["stationLogo", "stationLogoColored"]:
if logo_type in thumbnails:
original_url = thumbnails[logo_type].get("url")
if original_url:
logo_url = self._build_scaled_image_url(original_url)
break
magenta2_channel = Magenta2Channel(
name=title,
channel_id=channel_id,
logo_url=logo_url,
mode=MODE_LIVE,
content_type=CONTENT_TYPE_LIVE,
country=self._country,
raw_data=entry,
)
streaming_channel = magenta2_channel.to_streaming_channel(
provider_name=self._provider_name
)
streaming_channel.channel_number = display_number
streaming_channel.quality = station_info.get("dt$quality", "SD")
return streaming_channel
except Exception as e:
logger.warning(f"Error creating channel from entry: {e}")
return None
def _process_channel_stations_response_optimized(
self, response_data: Dict, prefer_highest_quality: bool = True
) -> List[StreamingChannel]:
"""Single-pass deduplication and channel construction from a raw feed response."""
if "entries" not in response_data:
return []
best_entries: Dict = {}
channels: List[StreamingChannel] = []
for entry in response_data["entries"]:
try:
stations = entry.get("stations", {})
if not stations:
continue
station_info = next(iter(stations.values()))
display_number = entry.get("dt$displayChannelNumber")
if display_number is None:
channel = self._create_channel_from_entry(
entry, station_info, display_number
)
if channel:
channels.append(channel)
continue
quality = station_info.get("dt$quality", "SD")
current_rank = QUALITY_RANK.get(quality, 1)
existing = best_entries.get(display_number)
if not existing:
best_entries[display_number] = (entry, station_info, current_rank)
else:
_, _, existing_rank = existing
if (prefer_highest_quality and current_rank > existing_rank) or (
not prefer_highest_quality and current_rank < existing_rank
):
best_entries[display_number] = (entry, station_info, current_rank)
except Exception:
continue
for display_number, (entry, station_info, _) in best_entries.items():
channel = self._create_channel_from_entry(entry, station_info, display_number)
if channel:
channels.append(channel)
return channels
def _get_api_headers(self, require_auth: bool = False) -> Dict[str, str]:
"""Build standard API request headers."""
headers = {
"User-Agent": self._platform_config["user_agent"],
"Accept": "application/json",
"Content-Type": "application/json",
}
if require_auth:
persona_token = self._ensure_authenticated()
headers["Authorization"] = f"Basic {persona_token}"
return headers
@@ -0,0 +1,221 @@
# streaming_providers/providers/magenta2/playback_manager.py
# -*- coding: utf-8 -*-
"""
Routes manifest and DRM requests for the Magenta2 provider.
Responsibilities
----------------
- Serve live-channel manifests directly from the ChannelManager cache
(avoiding a SMIL round-trip for channels already fetched by get_channels).
- Build Widevine licence URLs directly for live channels using lib_theplatform.
- Delegate VOD and recording manifest/DRM requests to SmilManager.
- Inject ``smil_base_url`` from the recording-URL cache so callers (e.g.
DRMOperations) only need to pass a ``content_id``.
- Provide ``get_catchup_manifest`` via SmilManager.
The class holds NO state of its own beyond references to the managers and
callbacks passed at construction time. All caches belong to ChannelManager.
"""
import base64
from typing import Callable, Dict, List, Optional
from ...base.models import DRMConfig
from ...base.utils.logger import logger
from .constants import CONTENT_TYPE_LIVE, MAGENTA2_FALLBACK_ACCOUNT_URI
from .endpoint_manager import EndpointManager
from .config_models import ProviderConfig
from .smil_manager import SmilManager
from .channel_manager import ChannelManager
from ..lib_theplatform import (
extract_persona_jwt,
build_licence_url,
build_widevine_drm_config,
)
class PlaybackManager:
"""
Routes manifest and DRM requests for Magenta2 content.
Parameters
----------
channel_manager:
The provider's ChannelManager instance (owns live-manifest/pid caches).
smil_manager:
The provider's SmilManager instance (handles VOD / recording SMIL).
endpoint_manager:
Populated EndpointManager after discovery.
provider_config:
ProviderConfig after discovery.
platform_config:
Platform-specific dict from MAGENTA2_PLATFORMS (user_agent, etc.).
auth_callback:
Callable[[], str] — returns a valid persona token (Basic-auth value).
recording_url_cache:
Shared dict (owned by the provider) mapping content_id → manifest_script
for recordings fetched via RecordingsManager.
"""
def __init__(
self,
channel_manager: ChannelManager,
smil_manager: Optional[SmilManager],
endpoint_manager: Optional[EndpointManager],
provider_config: Optional[ProviderConfig],
platform_config: Dict,
auth_callback: Callable[[], str],
recording_url_cache: Dict[str, str],
):
self._channel_manager = channel_manager
self._smil_manager = smil_manager
self._endpoint_manager = endpoint_manager
self._provider_config = provider_config
self._platform_config = platform_config
self._ensure_authenticated = auth_callback
self._recording_url_cache = recording_url_cache
# ------------------------------------------------------------------ #
# Public API #
# ------------------------------------------------------------------ #
def get_manifest(
self, content_id: str, content_type: str = CONTENT_TYPE_LIVE, **kwargs
) -> Optional[str]:
"""
Return the MPD manifest URL for *content_id*.
Live channels whose manifest was already fetched by get_channels() are
served directly from the ChannelManager cache — no SMIL round-trip.
VOD and recordings fall through to SmilManager.
"""
if content_type == CONTENT_TYPE_LIVE:
self._ensure_live_cache()
if content_id in self._channel_manager._live_manifest_cache:
logger.debug(f"get_manifest: cache hit for live channel {content_id}")
return self._channel_manager._live_manifest_cache[content_id]
if not self._smil_manager:
raise RuntimeError("SmilManager not available")
self._inject_smil_base_url(content_id, kwargs)
return self._smil_manager.get_manifest(content_id, content_type, **kwargs)
def get_drm(
self, content_id: str, content_type: str = CONTENT_TYPE_LIVE, **kwargs
) -> List[DRMConfig]:
"""
Return DRM configuration for *content_id*.
For live channels whose releasePid is cached, the Widevine licence URL
is built directly using lib_theplatform — no SMIL fetch needed.
VOD and recordings fall through to SmilManager.
"""
if content_type == CONTENT_TYPE_LIVE:
self._ensure_live_cache()
if content_id in self._channel_manager._live_pid_cache:
return self._build_live_drm(content_id)
if not self._smil_manager:
raise RuntimeError("SmilManager not available")
self._inject_smil_base_url(content_id, kwargs)
return self._smil_manager.get_drm(content_id, content_type, **kwargs)
def get_catchup_manifest(
self, channel_id: str, start_time: int, end_time: int, **kwargs
) -> Optional[str]:
"""Return a catchup manifest URL via SmilManager."""
if not self._smil_manager:
raise RuntimeError("SmilManager not available")
return self._smil_manager.get_catchup_manifest(
channel_id, start_time, end_time, **kwargs
)
# ------------------------------------------------------------------ #
# Internal helpers #
# ------------------------------------------------------------------ #
def _ensure_live_cache(self) -> None:
"""
Bootstrap the live-manifest / live-pid caches on demand by calling
ChannelManager.get_channels() if they are empty.
"""
if not self._channel_manager._live_manifest_cache:
logger.debug("Live channel cache is empty — auto-populating via get_channels()")
self._channel_manager.get_channels()
def _build_live_drm(self, content_id: str) -> List[DRMConfig]:
"""
Build a Widevine DRMConfig directly for a live channel, bypassing SMIL.
"""
release_pid = content_id
logger.debug(
f"get_drm: building licence directly for live channel (releasePid: {release_pid})"
)
try:
persona_token = self._ensure_authenticated()
raw_jwt = extract_persona_jwt(persona_token)
if not raw_jwt:
logger.error("get_drm: failed to extract persona JWT")
return []
widevine_endpoint = (
self._endpoint_manager.get_endpoint("widevine_license")
if self._endpoint_manager
else None
)
if not widevine_endpoint:
logger.error("get_drm: no widevine_license endpoint available")
return []
account_uri = (
self._provider_config.manifest.mpx.get_account_uri()
if self._provider_config and self._provider_config.manifest
else None
) or MAGENTA2_FALLBACK_ACCOUNT_URI
licence_url = build_licence_url(
widevine_endpoint=widevine_endpoint,
release_pid=release_pid,
persona_jwt=raw_jwt,
account_uri=account_uri,
)
return [
build_widevine_drm_config(
licence_url=licence_url,
user_agent=self._platform_config["user_agent"],
)
]
except Exception as exc:
logger.error(f"get_drm: direct licence build failed for {content_id}: {exc}")
return []
def _inject_smil_base_url(self, content_id: str, kwargs: Dict) -> None:
"""
If a recording manifest_script URL is cached for *content_id*, inject it
as ``smil_base_url`` into *kwargs* so SmilManager can use it without the
caller needing to know about it.
"""
if "smil_base_url" not in kwargs and content_id in self._recording_url_cache:
kwargs["smil_base_url"] = self._recording_url_cache[content_id]
logger.debug(f"Injected smil_base_url from recording cache for {content_id}")
@staticmethod
def extract_persona_jwt_from_token(persona_token: str) -> Optional[str]:
"""
Decode a Base64-encoded persona token and extract the raw JWT portion.
The persona token format is: ``Base64(account_uri + ":" + persona_jwt)``.
"""
try:
decoded = base64.b64decode(persona_token).decode("utf-8")
last_colon_index = decoded.rfind(":")
if last_colon_index == -1:
logger.error("No colon found in decoded persona token")
return None
persona_jwt = decoded[last_colon_index + 1:]
if not persona_jwt.startswith("eyJ"):
logger.error("Extracted token doesn't look like a JWT")
return None
return persona_jwt
except Exception as e:
logger.error(f"Error extracting persona JWT token: {e}")
return None
File diff suppressed because it is too large Load Diff