Files
script.service.ultimate/lib/streaming_providers/providers/lib_theplatform.py
T
2026-05-04 13:41:11 +02:00

453 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# streaming_providers/providers/lib_theplatform.py
"""
Shared theplatform playback utilities.
Both magenta2 (DE) and magentaeu (AT/PL/HR/HU/ME) ultimately deliver the
same three playback artefacts for every live channel:
mpd_url the MPEG-DASH manifest URL
release_pid the Widevine releasePid for licence acquisition
station_id the theplatform Station URI (used as the channel key)
They obtain those artefacts differently:
magenta2 → getApplicableDistributionRights → entitled-channels feed
magentaeu → bifrost /epg/channel
This module contains the logic that is identical once the artefacts are in
hand:
TheplatformChannel canonical dataclass for one channel's data
parse_entitled_channels_feed() parses the magenta2 JSON feed
parse_bifrost_epg_channel() parses one magentaeu bifrost entry
build_catchup_url() appends begin/end to any manifest URL
build_widevine_drm_config() builds a DRMConfig from a licence URL
build_licence_url() assembles the theplatform WV licence URL
extract_persona_jwt() decodes Base64 persona token → raw JWT
Usage (magenta2):
channels = parse_entitled_channels_feed(feed_json)
drm = build_widevine_drm_config(
build_licence_url(widevine_endpoint, ch.release_pid,
extract_persona_jwt(persona_token),
account_uri),
user_agent)
Usage (magentaeu):
ch = parse_bifrost_epg_channel(entry)
drm = build_widevine_drm_config(
build_licence_url(WV_URL, ch.release_pid,
persona_token_from_jwt_claim,
account_uri),
USER_AGENT)
"""
import base64
from dataclasses import dataclass, field
from typing import List, Optional
from urllib.parse import quote
from ..base.models import DRMConfig, DRMSystem, LicenseConfig
from ..base.utils.logger import logger
from ..base.utils.timestamp_converter import TimestampConverter
# ---------------------------------------------------------------------------
# Canonical channel dataclass
# ---------------------------------------------------------------------------
@dataclass
class TheplatformChannel:
"""
Normalised live-channel record produced by both provider families.
Attributes:
station_id: theplatform Station URI
e.g. "http://data.entertainment.tv.theplatform.eu/…/Station/12345"
Used as the channel's stable content_id.
mpd_url: Pre-resolved MPEG-DASH manifest URL.
release_pid: Widevine releasePid for licence acquisition.
hls_url: HLS manifest URL (optional present when available).
channel_number: Logical channel number (optional).
"""
station_id: str
mpd_url: str
release_pid: str
hls_url: Optional[str] = None
channel_number: Optional[int] = None
extra: dict = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Feed parsers
# ---------------------------------------------------------------------------
def parse_entitled_channels_feed(feed: dict) -> List[TheplatformChannel]:
"""
Parse the magenta2 entitled-channels feed response.
Expected shape (one entry):
{
"media": [{"content": [
{"format": "MPEG-DASH",
"releases": [{"pid": "uEkSomCvvBMX"}],
"streamingUrl": "https://…/DASH/index.mpd"},
{"format": "M3U",
"releases": [{"pid": "bMchet033yZ0"}],
"streamingUrl": "https://…/HLS_CMAF/index.m3u8"}
]}],
"listings": [{"stationId": "http://…/Station/265808936224"}],
"dt$channelNumber": 880
}
"""
channels: List[TheplatformChannel] = []
for entry in feed.get("entries", []):
try:
# Station ID
listings = entry.get("listings", [])
if not listings:
continue
station_id = listings[0].get("stationId", "")
if not station_id:
continue
# Content items
content_items = (entry.get("media") or [{}])[0].get("content", [])
mpd_url = ""
release_pid = ""
hls_url = None
for item in content_items:
fmt = item.get("format", "")
url = item.get("streamingUrl", "")
pid = (item.get("releases") or [{}])[0].get("pid", "")
if fmt == "MPEG-DASH" and url:
mpd_url = url
release_pid = pid
elif fmt == "M3U" and url:
hls_url = url
if not mpd_url:
logger.debug(f"lib_theplatform: no DASH URL for station {station_id}, skipping")
continue
channels.append(TheplatformChannel(
station_id=station_id,
mpd_url=mpd_url,
release_pid=release_pid,
hls_url=hls_url,
channel_number=entry.get("dt$channelNumber"),
extra={"distributionRightIds": entry.get("distributionRightIds", [])},
))
except Exception as exc:
logger.warning(f"lib_theplatform: error parsing feed entry: {exc}")
logger.debug(f"lib_theplatform: parsed {len(channels)} channels from entitled-channels feed")
return channels
def parse_bifrost_epg_channel(entry: dict) -> Optional[TheplatformChannel]:
"""
Parse a single channel entry from the magentaeu bifrost /epg/channel response.
Expected shape:
{
"station_id": "http://…/Station/12345", (or "stationId")
"video_src_dash": "https://…/DASH/index.mpd",
"pid_dash": "uEkSomCvvBMX",
"channel_number": 28
}
Both snake_case (bifrost) and camelCase (older variants) field names are
handled so this function is robust to minor API variations.
"""
try:
station_id = (
entry.get("station_id")
or entry.get("stationId")
or ""
)
mpd_url = (
entry.get("video_src_dash")
or entry.get("videoDashUrl")
or ""
)
release_pid = (
entry.get("pid_dash")
or entry.get("pidDash")
or ""
)
hls_url = entry.get("video_src_hls") or entry.get("videoHlsUrl")
channel_number = entry.get("channel_number") or entry.get("channelNumber")
if not mpd_url:
return None
return TheplatformChannel(
station_id=station_id,
mpd_url=mpd_url,
release_pid=release_pid,
hls_url=hls_url,
channel_number=channel_number,
)
except Exception as exc:
logger.warning(f"lib_theplatform: error parsing bifrost entry: {exc}")
return None
# ---------------------------------------------------------------------------
# Distribution-rights helper (magenta2)
# ---------------------------------------------------------------------------
def fetch_distribution_rights(
http_manager,
rights_url: str,
cid: str,
user_agent: str,
timeout: int = 30,
extra_headers: Optional[dict] = None,
) -> List[str]:
"""
Call getApplicableDistributionRights and return the list of right URLs.
Args:
http_manager: HTTPManager instance.
rights_url: manifest.mpx.license_service_url
(basicUrlGetApplicableDistributionRights)
cid: Correlation ID string "session_id::call_id"
user_agent: Platform user-agent string.
timeout: HTTP timeout in seconds.
extra_headers: Optional additional headers merged on top of the
default set (e.g. Authorization, Origin, Referer).
Caller-supplied keys take precedence.
Returns:
List of DistributionRight URL strings, empty on failure.
"""
try:
headers = {"User-Agent": user_agent, "Accept": "application/json"}
if extra_headers:
headers.update(extra_headers)
response = http_manager.get(
rights_url,
operation="get_distribution_rights",
headers=headers,
params={"form": "json", "schema": "1.2", "cid": cid},
timeout=timeout,
)
if response.status_code != 200:
logger.error(
f"lib_theplatform: distribution rights request failed [{response.status_code}]"
)
return []
data = response.json()
rights = data.get("getApplicableDistributionRightsResponse", [])
logger.debug(f"lib_theplatform: obtained {len(rights)} distribution rights")
return rights
except Exception as exc:
logger.error(f"lib_theplatform: error fetching distribution rights: {exc}")
return []
def fetch_entitled_channels_feed(
http_manager,
feed_url: str,
distribution_rights: List[str],
cid: str,
user_agent: str,
timeout: int = 30,
) -> List[TheplatformChannel]:
"""
Fetch the entitled-channels feed for the given distribution rights and
return parsed TheplatformChannel objects.
Handles pagination automatically (range=1-100, 101-200, …).
Args:
http_manager: HTTPManager instance.
feed_url: Resolved feed URL (MpxAccountPid already substituted).
e.g. "https://feed.entertainment.tv.theplatform.eu/f/mdeprod/mdeprod-entitled-channels"
distribution_rights: List of DistributionRight URL strings.
cid: Correlation ID string.
user_agent: Platform user-agent string.
timeout: HTTP timeout in seconds.
Returns:
List of TheplatformChannel, one per DASH-capable channel entry.
"""
if not distribution_rights:
logger.warning("lib_theplatform: no distribution rights — cannot fetch entitled channels")
return []
rights_param = "|".join(distribution_rights)
channels: List[TheplatformChannel] = []
page_size = 100
start = 1
while True:
try:
params = {
"byDistributionRightId": rights_param,
"range": f"{start}-{start + page_size - 1}",
"cid": cid,
}
response = http_manager.get(
feed_url,
operation="entitled_channels_feed",
headers={"User-Agent": user_agent, "Accept": "application/json"},
params=params,
timeout=timeout,
)
if response.status_code != 200:
logger.error(
f"lib_theplatform: entitled channels feed failed [{response.status_code}]"
)
break
data = response.json()
page_channels = parse_entitled_channels_feed(data)
channels.extend(page_channels)
entry_count = data.get("entryCount", 0)
if len(page_channels) < page_size or entry_count < page_size:
break
start += page_size
except Exception as exc:
logger.error(f"lib_theplatform: error fetching entitled channels page {start}: {exc}")
break
logger.info(f"lib_theplatform: fetched {len(channels)} entitled channels total")
return channels
# ---------------------------------------------------------------------------
# DRM helpers
# ---------------------------------------------------------------------------
def extract_persona_jwt(persona_token: str) -> Optional[str]:
"""
Extract the raw JWT from a Base64-encoded persona token.
Persona token format: Base64( account_uri + ":" + jwt )
This is the magenta2 format returned by the theplatform persona endpoint.
The magentaeu provider reads the JWT directly from the dc_cts_personaToken
JWT claim and therefore does not need this function.
Returns:
Raw JWT string starting with "eyJ", or None on failure.
"""
try:
decoded = base64.b64decode(persona_token).decode("utf-8")
idx = decoded.rfind(":")
if idx == -1:
logger.error("lib_theplatform: no colon in decoded persona token")
return None
jwt = decoded[idx + 1:]
if not jwt.startswith("eyJ"):
logger.error("lib_theplatform: extracted token does not look like a JWT")
return None
return jwt
except Exception as exc:
logger.error(f"lib_theplatform: error extracting persona JWT: {exc}")
return None
def build_licence_url(
widevine_endpoint: str,
release_pid: str,
persona_jwt: str,
account_uri: str,
) -> str:
"""
Assemble the theplatform Widevine licence acquisition URL.
Args:
widevine_endpoint: Base WV endpoint URL, e.g.
"https://widevine.entitlement.theplatform.eu/wv/web/ModularDrm/getRawWidevineLicense"
release_pid: releasePid extracted from the SMIL or channel feed.
persona_jwt: Raw JWT (not Base64-wrapped) for the authenticated user.
account_uri: MPX account URI, e.g.
"http://access.auth.theplatform.com/data/Account/2709353023"
Returns:
Complete licence URL string.
"""
sep = "&" if "?" in widevine_endpoint else "?"
return (
f"{widevine_endpoint}{sep}"
f"schema=1.0&"
f"releasePid={release_pid}&"
f"token={persona_jwt}&"
f"account={quote(account_uri, safe='')}"
)
def build_widevine_drm_config(
licence_url: str,
user_agent: str,
origin: Optional[str] = None,
) -> DRMConfig:
"""
Build a Widevine DRMConfig for theplatform licence acquisition.
Args:
licence_url: Complete licence URL (from build_licence_url).
user_agent: Platform user-agent string for the licence request.
origin: Optional Origin header value (used by magentaeu to set
the country base URL; omit for magenta2).
Returns:
DRMConfig ready for use by the base streaming provider.
"""
headers: dict = {
"User-Agent": user_agent,
"Content-Type": "application/octet-stream",
}
if origin:
headers["Origin"] = origin
headers["Referer"] = f"{origin}/"
return DRMConfig(
system=DRMSystem.WIDEVINE,
priority=2,
license=LicenseConfig(
req_data="{CHA-RAW}",
server_url=licence_url,
server_certificate=None,
req_headers=headers,
use_http_get_request=False,
),
)
# ---------------------------------------------------------------------------
# Catchup helper
# ---------------------------------------------------------------------------
def build_catchup_url(base_manifest: str, start_time: int, end_time: int) -> str:
"""
Append begin/end query parameters to a live manifest URL for catchup.
Args:
base_manifest: Live DASH manifest URL (with or without existing params).
start_time: Catchup start as Unix epoch seconds.
end_time: Catchup end as Unix epoch seconds.
Returns:
Manifest URL extended with ?begin=YYYYMMDDTHHMMSSZ&end=YYYYMMDDTHHMMSSZ
"""
start_iso = TimestampConverter.epoch_to_iso(start_time, format_type="basic", as_utc=True)
end_iso = TimestampConverter.epoch_to_iso(end_time, format_type="basic", as_utc=True)
sep = "&" if "?" in base_manifest else "?"
return f"{base_manifest}{sep}begin={start_iso}&end={end_iso}"