mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-28 17:52:58 +01:00
magenta2: add fallback with generic
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# streaming_providers/providers/lib_theplatform.py
|
||||
"""
|
||||
Shared theplatform playback utilities.
|
||||
Shared theplatform playback utilities — provider-agnostic only.
|
||||
|
||||
Both magenta2 (DE) and magentaeu (AT/PL/HR/HU/ME) ultimately deliver the
|
||||
same three playback artefacts for every live channel:
|
||||
@@ -9,37 +9,31 @@ same three playback artefacts for every live channel:
|
||||
release_pid – the Widevine releasePid for licence acquisition
|
||||
station_id – the theplatform Station URI (used as the channel key)
|
||||
|
||||
They obtain those artefacts differently:
|
||||
HOW each provider discovers those artefacts is provider-specific and lives
|
||||
in the provider packages — never here:
|
||||
|
||||
magenta2 → getApplicableDistributionRights → entitled-channels feed
|
||||
magentaeu → bifrost /epg/channel
|
||||
magenta2 → getApplicableDistributionRights + entitled-channels feed
|
||||
(streaming_providers/providers/magenta2/channel_manager.py)
|
||||
magentaeu → bifrost /epg/channel (entry parser below; the fetch
|
||||
itself lives in the magentaeu provider)
|
||||
|
||||
This module contains the logic that is identical once the artefacts are in
|
||||
hand:
|
||||
This module contains only what 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
|
||||
TheplatformChannel – canonical dataclass for one channel
|
||||
PaginationError / paginate_feed() – generic feed pagination
|
||||
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 a Base64 thePlatform 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)
|
||||
Usage (both providers, once release_pid + persona token are in hand):
|
||||
drm = build_widevine_drm_config(
|
||||
build_licence_url(WV_URL, ch.release_pid,
|
||||
persona_token_from_jwt_claim,
|
||||
build_licence_url(widevine_endpoint, release_pid,
|
||||
extract_persona_jwt(persona_token),
|
||||
account_uri),
|
||||
USER_AGENT)
|
||||
user_agent)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -158,77 +152,9 @@ class TheplatformChannel:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feed parsers
|
||||
# magentaeu feed parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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.
|
||||
@@ -279,128 +205,20 @@ def parse_bifrost_epg_channel(entry: dict) -> Optional[TheplatformChannel]:
|
||||
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]:
|
||||
if not distribution_rights:
|
||||
logger.warning("lib_theplatform: no distribution rights — cannot fetch entitled channels")
|
||||
return []
|
||||
|
||||
rights_param = "|".join(distribution_rights)
|
||||
headers = {"User-Agent": user_agent, "Accept": "application/json"}
|
||||
|
||||
def fetch_page(start_index: int, page_size: int) -> Optional[Dict[str, Any]]:
|
||||
params = {
|
||||
"byDistributionRightId": rights_param,
|
||||
"range": f"{start_index}-{start_index + page_size - 1}",
|
||||
"cid": cid,
|
||||
}
|
||||
try:
|
||||
response = http_manager.get(
|
||||
feed_url,
|
||||
operation="entitled_channels_feed",
|
||||
headers=headers,
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as exc:
|
||||
logger.warning(f"lib_theplatform: entitled channels page fetch failed: {exc}")
|
||||
return None
|
||||
|
||||
try:
|
||||
entries = paginate_feed(
|
||||
fetch_page, items_per_page=100, max_pages=20, feed_name="entitled_channels"
|
||||
)
|
||||
except PaginationError as exc:
|
||||
if not exc.partial_entries:
|
||||
logger.error(f"lib_theplatform: {exc} — no entitled channels fetched")
|
||||
raise
|
||||
logger.error(
|
||||
f"lib_theplatform: {exc} — channel lineup INCOMPLETE, "
|
||||
f"using {len(exc.partial_entries)} raw entries fetched before failure"
|
||||
)
|
||||
entries = exc.partial_entries
|
||||
|
||||
channels = parse_entitled_channels_feed({"entries": entries})
|
||||
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.
|
||||
Extract the raw JWT from a Base64-encoded thePlatform 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
|
||||
This is the thePlatform persona-token encoding as returned by the
|
||||
magenta2 persona endpoint; the decoding itself is provider-agnostic
|
||||
(pure format utility — no endpoints, params or feed semantics). The
|
||||
magentaeu provider reads the JWT directly from the dc_cts_personaToken
|
||||
JWT claim and therefore does not need this function.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"""
|
||||
Manages channel discovery, metadata enrichment, entitlement, and streaming-data
|
||||
population for the Magenta2 provider.
|
||||
|
||||
This module also owns the magenta2 thePlatform feed pipeline — distribution
|
||||
rights, entitled-channels fetch/parse and the playback-time fallback's
|
||||
generic-right query. It deliberately does NOT live in the shared
|
||||
lib_theplatform.py, which keeps only provider-agnostic utilities.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
@@ -10,12 +15,12 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Callable, Dict, List, Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from ...base.errors import AuthPendingError, ProviderError
|
||||
from ...base.models import StreamingChannel
|
||||
from ...base.utils.logger import logger
|
||||
from ..lib_theplatform import paginate_feed, PaginationError
|
||||
from ..lib_theplatform import PaginationError, paginate_feed, TheplatformChannel
|
||||
from .constants import (
|
||||
CONTENT_TYPE_LIVE,
|
||||
DEFAULT_EPG_WINDOW_HOURS,
|
||||
@@ -30,10 +35,24 @@ from .constants import (
|
||||
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,
|
||||
|
||||
# "DT baseline" distribution right carried by (effectively) every entry of
|
||||
# the mdeprod entitled-channels feed — every Sky-family, DFB, RedBullTV,
|
||||
# MoreThanSports, DABEI … entry in a captured feed carries it. It is NOT
|
||||
# one of the account's applicable (entitlement) rights: querying the feed
|
||||
# with it enumerates the FULL channel universe (~436 entries) instead of
|
||||
# the account-entitled subset (~255 that the applicable rights select).
|
||||
# The playback-time fallback resolver (resolve_release_pid_via_feed →
|
||||
# _fetch_full_channel_universe) queries with exactly this right so
|
||||
# user-agent-gated stations (UserAgentBlocked error SMILs — observed for a
|
||||
# Sky Sport UHD station) can still be resolved to a release pid + direct
|
||||
# streamingUrl. Entitlement is NOT weakened: the Widevine licence server
|
||||
# remains the enforcement point; the filter only decides which entries the
|
||||
# resolution table holds. Mirrors the manually verified query:
|
||||
# .../mdeprod-entitled-channels
|
||||
# ?byDistributionRightId=…/DistributionRight/268234924&range=1-1000
|
||||
_GENERIC_DISTRIBUTION_RIGHT_URL = (
|
||||
"http://data.entitlement.theplatform.eu/eds/data/DistributionRight/268234924"
|
||||
)
|
||||
|
||||
|
||||
@@ -158,9 +177,14 @@ class ChannelManager:
|
||||
|
||||
try:
|
||||
# ── Steps 1+2: distribution rights + entitled-channels feed ──
|
||||
# Shared with the playback-time fallback
|
||||
# (resolve_release_pid_via_feed) so both paths issue
|
||||
# byte-identical requests.
|
||||
# The account's ENTITLED lineup (feed filtered by the
|
||||
# applicable distribution rights). NOT shared with the
|
||||
# playback-time fallback (resolve_release_pid_via_feed →
|
||||
# _fetch_full_channel_universe), which deliberately queries the
|
||||
# GENERIC distribution right instead — the full channel
|
||||
# universe — because it exists for stations OUTSIDE this
|
||||
# account's entitled subset (observed: Sky Sport UHD absent
|
||||
# from the 255-entry lineup, UserAgentBlocked on the SMIL path).
|
||||
distribution_rights, tp_channels = self._fetch_entitled_feed_data()
|
||||
|
||||
def _dist_uri_to_int(uri) -> Optional[int]:
|
||||
@@ -407,12 +431,20 @@ class ChannelManager:
|
||||
manifest = channel_manager.get_live_manifest_url(pid)
|
||||
drm = live fast-path construction with release_pid=pid
|
||||
|
||||
The feed is queried with the GENERIC distribution right
|
||||
(_GENERIC_DISTRIBUTION_RIGHT_URL) — the FULL channel universe, NOT
|
||||
the account's applicable rights. The fallback exists precisely for
|
||||
stations outside the entitled subset; filtering by the applicable
|
||||
rights would hide them (observed: the 12-right query returns 255
|
||||
entries, Sky Sport UHD not among them, while the generic-right
|
||||
query returns 436 including it).
|
||||
|
||||
Behaviour:
|
||||
* Vouched fast path: if the station already maps to a pid that is
|
||||
in the entitled pid cache, it is returned WITHOUT a fetch.
|
||||
* Rate limit: at most one feed refetch per
|
||||
ENTITLED_FEED_FALLBACK_MIN_INTERVAL seconds per instance, so a
|
||||
genuinely unentitled station cannot hammer the feed on every
|
||||
genuinely unavailable station cannot hammer the feed on every
|
||||
playback attempt. force=True bypasses (user-initiated retry,
|
||||
admin refresh). Concurrent callers serialise on a lock; the
|
||||
loser profits from the winner's refreshed maps.
|
||||
@@ -426,9 +458,9 @@ class ChannelManager:
|
||||
cached channel list is dropped so the next get_channels() rebuilds
|
||||
with the fresh pids.
|
||||
|
||||
Returns the release pid, or None if the station is not in the feed
|
||||
(not entitled on this account) or the feed could not be fetched.
|
||||
Never raises.
|
||||
Returns the release pid, or None if the station is not even in the
|
||||
full channel universe (feed queried under the generic right) or
|
||||
the feed could not be fetched. Never raises.
|
||||
"""
|
||||
vouched = self._vouched_pid_for_station(station_id)
|
||||
if vouched:
|
||||
@@ -460,7 +492,7 @@ class ChannelManager:
|
||||
f"path unusable)"
|
||||
)
|
||||
try:
|
||||
_, tp_channels = self._fetch_entitled_feed_data()
|
||||
tp_channels = self._fetch_full_channel_universe()
|
||||
applied = 0
|
||||
for tp_ch in tp_channels:
|
||||
sid = self._extract_station_id(tp_ch.station_id)
|
||||
@@ -501,9 +533,9 @@ class ChannelManager:
|
||||
|
||||
logger.warning(
|
||||
f"resolve_release_pid_via_feed: station {station_id} is not in "
|
||||
f"the entitled-channels feed (not entitled on this account, or "
|
||||
f"its distribution right is not among the byDistributionRightId "
|
||||
f"values the feed was queried with)"
|
||||
f"the entitled-channels feed even under the generic "
|
||||
f"distribution right (full universe, {applied} entries "
|
||||
f"applied) — no release pid / streamingUrl exists for it"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -778,31 +810,98 @@ class ChannelManager:
|
||||
self._live_hls_cache[playback_id] = hls_url
|
||||
self._live_pid_cache[playback_id] = playback_id
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# thePlatform feed pipeline (magenta2-specific — lives HERE, not in #
|
||||
# the shared lib_theplatform) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _fetch_entitled_feed_data(
|
||||
self,
|
||||
) -> Tuple[List[str], List[TheplatformChannel]]:
|
||||
"""
|
||||
Steps 1-2 shared by get_channels() and the playback-time feed
|
||||
fallback (resolve_release_pid_via_feed):
|
||||
Steps 1-2 of get_channels(): the account's ENTITLED lineup.
|
||||
|
||||
1. getApplicableDistributionRights — license_service_url with
|
||||
Basic persona auth (cid correlation header).
|
||||
2. mdeprod-entitled-channels feed — queried with
|
||||
byDistributionRightId (see _append_distribution_rights_param:
|
||||
WITHOUT this param the feed serves only its default set,
|
||||
which excludes rights-gated premium channels — observed in
|
||||
production: 255 base entries, station 752027688388 absent,
|
||||
while the same feed queried with the rights param returns
|
||||
its DASH release pid), paginated by
|
||||
fetch_entitled_channels_feed. If the filtered fetch fails or
|
||||
comes back empty, it is retried ONCE unfiltered so the
|
||||
added param can never blank the channel list.
|
||||
2. mdeprod-entitled-channels feed, filtered by those rights
|
||||
(pipe-joined byDistributionRightId — see
|
||||
_fetch_entitled_feed_channels, the single owner of that
|
||||
parameter).
|
||||
|
||||
Deliberately NOT shared with the playback-time fallback
|
||||
(resolve_release_pid_via_feed → _fetch_full_channel_universe):
|
||||
that path queries the GENERIC distribution right instead, because
|
||||
it exists for stations OUTSIDE the entitled subset.
|
||||
|
||||
Resilience: if the rights-filtered query fails or returns nothing,
|
||||
it is retried once UNFILTERED (feed default set) so a param form
|
||||
the server rejects can never blank the channel list. Playback
|
||||
entitlement is enforced by the licence server, not by this list.
|
||||
|
||||
Returns (distribution_rights, tp_channels); raises on failure so
|
||||
callers choose their own degradation (get_channels wraps it into a
|
||||
ProviderError-preserving exception chain, the fallback logs and
|
||||
returns None).
|
||||
"""
|
||||
distribution_rights = self._fetch_distribution_rights()
|
||||
if not distribution_rights:
|
||||
logger.warning(
|
||||
"_fetch_entitled_feed_data: no distribution rights — cannot "
|
||||
"fetch the entitled lineup"
|
||||
)
|
||||
return [], []
|
||||
|
||||
try:
|
||||
tp_channels: List[TheplatformChannel] = self._fetch_entitled_feed_channels(
|
||||
distribution_rights
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"_fetch_entitled_feed_data: rights-filtered feed fetch failed "
|
||||
f"({exc}); retrying unfiltered (feed default set)"
|
||||
)
|
||||
tp_channels = self._fetch_entitled_feed_channels()
|
||||
|
||||
if not tp_channels:
|
||||
# "Server accepted the param but matched nothing" — the account
|
||||
# demonstrably has packages, so an empty filtered result is a
|
||||
# query problem, not an entitlement state.
|
||||
logger.warning(
|
||||
"_fetch_entitled_feed_data: rights-filtered feed returned no "
|
||||
"channels — retrying unfiltered (feed default set)"
|
||||
)
|
||||
tp_channels = self._fetch_entitled_feed_channels()
|
||||
|
||||
return distribution_rights, tp_channels
|
||||
|
||||
def _fetch_full_channel_universe(self) -> List[TheplatformChannel]:
|
||||
"""
|
||||
Playback-time fallback fetch: the FULL channel universe via the
|
||||
generic DT baseline distribution right, ONE request — mirroring
|
||||
the manually verified query:
|
||||
|
||||
.../mdeprod-entitled-channels
|
||||
?byDistributionRightId=…/DistributionRight/268234924&range=1-1000
|
||||
|
||||
Every entry carries a release pid + direct streamingUrl (DASH MPD,
|
||||
plus HLS where present). Entitlement is NOT weakened by this: the
|
||||
Widevine licence server remains the enforcement point; this table
|
||||
only resolves station -> pid + URL.
|
||||
|
||||
Raises on failure; the caller (resolve_release_pid_via_feed)
|
||||
catches, logs and degrades.
|
||||
"""
|
||||
return self._fetch_entitled_feed_channels(
|
||||
distribution_rights=[_GENERIC_DISTRIBUTION_RIGHT_URL],
|
||||
items_per_page=1000,
|
||||
)
|
||||
|
||||
def _fetch_distribution_rights(self) -> List[str]:
|
||||
"""
|
||||
getApplicableDistributionRights: the account's applicable
|
||||
DistributionRight URIs. Empty list on request failure; raises
|
||||
RuntimeError when no license_service_url is configured.
|
||||
"""
|
||||
rights_url = (
|
||||
self._provider_config.manifest.mpx.license_service_url
|
||||
if self._provider_config and self._provider_config.manifest
|
||||
@@ -811,130 +910,229 @@ class ChannelManager:
|
||||
if not rights_url:
|
||||
raise RuntimeError("No license_service_url available")
|
||||
|
||||
cid = f"{self._session_id}::{uuid.uuid4()}"
|
||||
user_agent = self._ua_subscriber
|
||||
|
||||
persona_token = self._ensure_authenticated()
|
||||
auth_headers = {
|
||||
headers = {
|
||||
"User-Agent": self._ua_subscriber,
|
||||
"Accept": "application/json",
|
||||
"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,
|
||||
)
|
||||
try:
|
||||
response = self._http.get(
|
||||
rights_url,
|
||||
operation="get_distribution_rights",
|
||||
headers=headers,
|
||||
params={
|
||||
"form": "json",
|
||||
"schema": "1.2",
|
||||
"cid": f"{self._session_id}::{uuid.uuid4()}",
|
||||
},
|
||||
timeout=DEFAULT_REQUEST_TIMEOUT,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"_fetch_distribution_rights: request failed "
|
||||
f"[{response.status_code}]"
|
||||
)
|
||||
return []
|
||||
|
||||
rights = response.json().get("getApplicableDistributionRightsResponse", [])
|
||||
logger.debug(f"_fetch_distribution_rights: obtained {len(rights)} distribution rights")
|
||||
return rights
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"_fetch_distribution_rights: error fetching distribution rights: {exc}")
|
||||
return []
|
||||
|
||||
def _fetch_entitled_feed_channels(
|
||||
self,
|
||||
distribution_rights: Optional[List[str]] = None,
|
||||
items_per_page: int = 100,
|
||||
) -> List[TheplatformChannel]:
|
||||
"""
|
||||
Fetch the entitled-channels feed and parse it into
|
||||
TheplatformChannel records.
|
||||
|
||||
SINGLE OWNER of the ``byDistributionRightId`` parameter: any copy
|
||||
already present in the endpoint URL is stripped
|
||||
(_strip_distribution_rights_params), then the parameter is added
|
||||
exactly ONCE via request params, pipe-joined (``id1|id2``) — the
|
||||
form the feed's own description mandates ("Must be queried with
|
||||
byDistritbutionRightId=id1|id2..."). Baking it into the URL AND
|
||||
sending it as a param returns ``400 BadParameterException:
|
||||
Duplicate query parameters are not allowed`` (observed in
|
||||
production).
|
||||
|
||||
Args:
|
||||
distribution_rights: DistributionRight URIs to filter by.
|
||||
None queries the feed's DEFAULT result set (used only by
|
||||
_fetch_entitled_feed_data's resilience retries).
|
||||
items_per_page: 100 paginates (lineup path); 1000 fetches the
|
||||
whole feed in ONE request, range=1-1000 (fallback path,
|
||||
mirroring the manually verified generic-right query).
|
||||
|
||||
Raises PaginationError (via paginate_feed) when a page fails with
|
||||
no partial data; partial data is used (with a loud log) when some
|
||||
pages succeeded.
|
||||
"""
|
||||
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"
|
||||
|
||||
# The one layer this provider controls end-to-end: bake the
|
||||
# account's distribution rights into the query so rights-gated
|
||||
# premium channels (Sky Sport UHD, …) come back from the feed.
|
||||
# fetch_entitled_channels_feed paginates but does not add this
|
||||
# filter itself (observed in production logs: the feed GET goes
|
||||
# out with range only, and the response is the feed's default
|
||||
# set). The param is percent-encoded, comma-joined, and appended
|
||||
# once; an endpoint template that already carries it wins.
|
||||
filtered_url = self._append_distribution_rights_param(
|
||||
feed_url, distribution_rights
|
||||
# Single-owner guard: never send byDistributionRightId twice.
|
||||
feed_url = self._strip_distribution_rights_params(feed_url)
|
||||
|
||||
cid = f"{self._session_id}::{uuid.uuid4()}"
|
||||
headers = {"User-Agent": self._ua_subscriber, "Accept": "application/json"}
|
||||
rights_param = "|".join(distribution_rights) if distribution_rights else None
|
||||
|
||||
logger.debug(
|
||||
f"_fetch_entitled_feed_channels: {feed_url} "
|
||||
f"[byDistributionRightId: {'|'.join(distribution_rights) if distribution_rights else '(none — default set)'}]"
|
||||
)
|
||||
if filtered_url != feed_url:
|
||||
logger.debug(f"entitled feed URL (rights-filtered): {filtered_url}")
|
||||
|
||||
def _fetch_page(start_index: int, page_size: int) -> Optional[Dict]:
|
||||
params: Dict[str, str] = {
|
||||
"range": f"{start_index}-{start_index + page_size - 1}",
|
||||
"cid": cid,
|
||||
}
|
||||
if rights_param:
|
||||
params["byDistributionRightId"] = rights_param
|
||||
try:
|
||||
response = self._http.get(
|
||||
feed_url,
|
||||
operation="entitled_channels_feed",
|
||||
headers=headers,
|
||||
params=params,
|
||||
timeout=DEFAULT_REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as exc:
|
||||
logger.warning(f"_fetch_entitled_feed_channels: page fetch failed: {exc}")
|
||||
return None
|
||||
|
||||
try:
|
||||
tp_channels: List[TheplatformChannel] = fetch_entitled_channels_feed(
|
||||
http_manager=self._http,
|
||||
feed_url=filtered_url,
|
||||
distribution_rights=distribution_rights,
|
||||
cid=cid,
|
||||
user_agent=user_agent,
|
||||
timeout=DEFAULT_REQUEST_TIMEOUT,
|
||||
entries = paginate_feed(
|
||||
_fetch_page,
|
||||
items_per_page=items_per_page,
|
||||
max_pages=20,
|
||||
feed_name="entitled_channels",
|
||||
)
|
||||
except Exception as exc:
|
||||
if filtered_url == feed_url:
|
||||
except PaginationError as exc:
|
||||
if not exc.partial_entries:
|
||||
logger.error(f"_fetch_entitled_feed_channels: {exc} — no channels fetched")
|
||||
raise
|
||||
# Safety net: a param the server rejects must not take the
|
||||
# whole channel list down — retry the query that has always
|
||||
# worked and surface the failure loudly.
|
||||
logger.warning(
|
||||
f"_fetch_entitled_feed_data: rights-filtered feed fetch failed "
|
||||
f"({exc}); retrying unfiltered feed {feed_url}"
|
||||
)
|
||||
tp_channels = 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,
|
||||
logger.error(
|
||||
f"_fetch_entitled_feed_channels: {exc} — channel lineup "
|
||||
f"INCOMPLETE, using {len(exc.partial_entries)} raw entries "
|
||||
f"fetched before failure"
|
||||
)
|
||||
entries = exc.partial_entries
|
||||
|
||||
if not tp_channels and filtered_url != feed_url:
|
||||
# Same safety net for the "server accepted the param but matched
|
||||
# nothing" case (e.g. a multi-value form it does not support):
|
||||
# the account demonstrably has packages, so an empty filtered
|
||||
# result is a query problem, not an entitlement state.
|
||||
logger.warning(
|
||||
"_fetch_entitled_feed_data: rights-filtered feed returned no "
|
||||
"channels — retrying unfiltered (check that the server "
|
||||
"accepts the byDistributionRightId value form)"
|
||||
)
|
||||
tp_channels = 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,
|
||||
)
|
||||
return distribution_rights, tp_channels
|
||||
channels = self._parse_entitled_feed_entries(entries)
|
||||
logger.info(f"_fetch_entitled_feed_channels: fetched {len(channels)} channels total")
|
||||
return channels
|
||||
|
||||
@staticmethod
|
||||
def _append_distribution_rights_param(
|
||||
feed_url: str, distribution_rights: List[str]
|
||||
) -> str:
|
||||
def _strip_distribution_rights_params(feed_url: str) -> str:
|
||||
"""
|
||||
Append ``byDistributionRightId`` to *feed_url*, thePlatform-style.
|
||||
Remove any ``byDistributionRightId`` parameter from *feed_url*
|
||||
(case-insensitive; path and all other parameters preserved).
|
||||
|
||||
Why here: the player's proven working request for the entitled
|
||||
feed is the endpoint template plus
|
||||
``byDistributionRightId=http://data.entitlement.theplatform.eu/eds/data/DistributionRight/<id>``
|
||||
(full right URIs, comma-joined for several values, plus range).
|
||||
Without the param the feed answers with its DEFAULT result set,
|
||||
which excludes rights-gated premium channels — the production
|
||||
failure this helper exists for: station 752027688388 (Sky Sport
|
||||
UHD) was absent from the default set, its station->pid map entry
|
||||
stayed an unvouched era$mediaPids media pid (d94Qlc88N3kS), SMIL
|
||||
on that pid returned UserAgentBlocked, and the feed fallback
|
||||
honestly reported "not in the feed" because with the unfiltered
|
||||
query it genuinely wasn't.
|
||||
|
||||
Rules: idempotent (skipped when the URL already carries the
|
||||
param — an endpoint-manager template with its own filter wins),
|
||||
order-preserving, de-duplicated, percent-encoded as one value
|
||||
(commas encoded too, decoded server-side by thePlatform EDS).
|
||||
_fetch_entitled_feed_channels is the SINGLE OWNER of that
|
||||
parameter — the feed rejects duplicates with
|
||||
``400 BadParameterException: Duplicate query parameters are not
|
||||
allowed`` (observed when a copy baked into the URL met a second
|
||||
copy sent via request params). Malformed URLs pass through
|
||||
untouched and fail loudly at request time.
|
||||
"""
|
||||
if not distribution_rights:
|
||||
try:
|
||||
parts = urlsplit(feed_url)
|
||||
if not parts.query:
|
||||
return feed_url
|
||||
kept = [
|
||||
(key, value)
|
||||
for key, value in parse_qsl(parts.query, keep_blank_values=True)
|
||||
if key.lower() != "bydistributionrightid"
|
||||
]
|
||||
return urlunsplit(parts._replace(query=urlencode(kept)))
|
||||
except Exception:
|
||||
return feed_url
|
||||
if "bydistributionrightid=" in feed_url.lower():
|
||||
return feed_url
|
||||
rights = [str(r).strip() for r in distribution_rights if str(r).strip()]
|
||||
if not rights:
|
||||
return feed_url
|
||||
seen = set()
|
||||
ordered = [r for r in rights if not (r in seen or seen.add(r))]
|
||||
param = quote(",".join(ordered), safe="")
|
||||
sep = "&" if "?" in feed_url else "?"
|
||||
return f"{feed_url}{sep}byDistributionRightId={param}"
|
||||
|
||||
@staticmethod
|
||||
def _parse_entitled_feed_entries(entries: List[Dict]) -> List[TheplatformChannel]:
|
||||
"""
|
||||
Parse raw entitled-channels feed entries into TheplatformChannel
|
||||
records.
|
||||
|
||||
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,
|
||||
"distributionRightIds": ["http://…/DistributionRight/268234924", …]
|
||||
}
|
||||
"""
|
||||
channels: List[TheplatformChannel] = []
|
||||
|
||||
for entry in 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"_parse_entitled_feed_entries: 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"_parse_entitled_feed_entries: error parsing feed entry: {exc}")
|
||||
|
||||
logger.debug(f"_parse_entitled_feed_entries: parsed {len(channels)} channels from entitled-channels feed")
|
||||
return channels
|
||||
|
||||
def _fetch_station_metadata(self) -> Dict[str, Dict]:
|
||||
metadata: Dict[str, Dict] = {}
|
||||
@@ -1045,7 +1243,7 @@ class ChannelManager:
|
||||
`profile` comes from station_metadata[station_id]["special_rights_profile"],
|
||||
which _fetch_station_metadata reads from the station feed's
|
||||
dt$clientData.specialRightsProfile. It is NOT read from the
|
||||
entitled-channels feed's tp_ch.extra — parse_entitled_channels_feed
|
||||
entitled-channels feed's tp_ch.extra — _parse_entitled_feed_entries
|
||||
only carries distributionRightIds into `extra`, never dt$clientData,
|
||||
so reading it from there would always fail open (nothing filtered).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user