diff --git a/lib/streaming_providers/providers/lib_theplatform.py b/lib/streaming_providers/providers/lib_theplatform.py index 884b845..da08f07 100644 --- a/lib/streaming_providers/providers/lib_theplatform.py +++ b/lib/streaming_providers/providers/lib_theplatform.py @@ -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: diff --git a/lib/streaming_providers/providers/magenta2/channel_manager.py b/lib/streaming_providers/providers/magenta2/channel_manager.py index 046a954..dd6ae00 100644 --- a/lib/streaming_providers/providers/magenta2/channel_manager.py +++ b/lib/streaming_providers/providers/magenta2/channel_manager.py @@ -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/`` - (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).