mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-08 08:52:14 +02:00
magenta2: Improve bootstrap
This commit is contained in:
@@ -456,6 +456,8 @@ def build_widevine_drm_config(
|
||||
licence_url: str,
|
||||
user_agent: str,
|
||||
origin: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
call_id: Optional[str] = None,
|
||||
) -> DRMConfig:
|
||||
"""
|
||||
Build a Widevine DRMConfig for theplatform licence acquisition.
|
||||
@@ -465,6 +467,14 @@ def build_widevine_drm_config(
|
||||
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).
|
||||
session_id: Optional — if provided together with call_id, adds a
|
||||
CID header formatted as "{session_id}::{call_id}",
|
||||
matching the SMIL DRM path and confirmed against a
|
||||
real ATV widevine capture (which shows CID but no
|
||||
separate session-id header). session_id alone (without
|
||||
call_id) adds nothing — additive; callers that don't
|
||||
pass either keep prior behaviour unchanged.
|
||||
call_id: Optional — see session_id above.
|
||||
|
||||
Returns:
|
||||
DRMConfig ready for use by the base streaming provider.
|
||||
@@ -476,6 +486,8 @@ def build_widevine_drm_config(
|
||||
if origin:
|
||||
headers["Origin"] = origin
|
||||
headers["Referer"] = f"{origin}/"
|
||||
if session_id and call_id:
|
||||
headers["CID"] = f"{session_id}::{call_id}"
|
||||
|
||||
return DRMConfig(
|
||||
system=DRMSystem.WIDEVINE,
|
||||
|
||||
@@ -23,15 +23,15 @@ from ...base.auth.base_oauth2_auth import OIDCConfiguration
|
||||
from ...base.auth.credentials import ClientCredentials
|
||||
from ...base.utils.logger import logger
|
||||
from .constants import (
|
||||
APPVERSION2,
|
||||
DEFAULT_COUNTRY,
|
||||
DEFAULT_PLATFORM,
|
||||
IDM,
|
||||
MAGENTA2_CLIENT_IDS,
|
||||
MAGENTA2_LEGACY_CLIENT_IDS,
|
||||
MAGENTA2_PLATFORMS,
|
||||
SSO_USER_AGENT,
|
||||
SUPPORTED_COUNTRIES,
|
||||
TAA_REQUEST_TEMPLATE,
|
||||
render_user_agent,
|
||||
)
|
||||
|
||||
# Import Magenta2-specific components
|
||||
@@ -58,10 +58,19 @@ class Magenta2Credentials(ClientCredentials):
|
||||
if not hasattr(self, "client_secret") or self.client_secret is None:
|
||||
self.client_secret = "" # Empty string for public client
|
||||
|
||||
# Set client_id from constant if not provided
|
||||
# client_id is no longer defaulted here. The caller (provider.py)
|
||||
# supplies it explicitly — initially a placeholder from
|
||||
# MAGENTA2_LEGACY_CLIENT_IDS, then the real, server-provided
|
||||
# sam3ClientId once bootstrap discovery completes (see
|
||||
# Magenta2Authenticator._configure_authenticator_from_discovery /
|
||||
# provider.py's post-discovery update). Not defaulting here prevents
|
||||
# silently re-introducing a stale/legacy client_id if a caller
|
||||
# forgets to pass one.
|
||||
if not self.client_id:
|
||||
self.client_id = MAGENTA2_CLIENT_IDS.get(
|
||||
self.platform, MAGENTA2_CLIENT_IDS[DEFAULT_PLATFORM]
|
||||
raise ValueError(
|
||||
"Magenta2Credentials requires an explicit client_id "
|
||||
"(pass a MAGENTA2_LEGACY_CLIENT_IDS placeholder pre-discovery, "
|
||||
"or the bootstrap-provided sam3ClientId post-discovery)."
|
||||
)
|
||||
|
||||
# Generate device ID if not provided
|
||||
@@ -87,21 +96,48 @@ class Magenta2Credentials(ClientCredentials):
|
||||
self.platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM]
|
||||
)
|
||||
|
||||
# Use provided models or fallback to platform defaults
|
||||
# Single source of truth for the version: MAGENTA2_PLATFORMS[platform]
|
||||
# ["version"] — the same value used to render the User-Agent. A real
|
||||
# TAA capture confirms appVersion in both the JSON body and the
|
||||
# keyValue string is identical to the version embedded in the UA
|
||||
# (e.g. "3.134.4462" in both places) — there is no separate,
|
||||
# independently-tracked TAA version.
|
||||
version = platform_config["version"]
|
||||
|
||||
# device_name is unchanged as a key (still exists in
|
||||
# MAGENTA2_PLATFORMS), only its value changed — from "Android TV" to
|
||||
# the more specific "SHIELD Android TV" for the ATV platforms.
|
||||
resolved_device_model = device_model or platform_config["device_name"]
|
||||
resolved_client_model = client_model or f"ftv-{self.platform}"
|
||||
|
||||
# Do NOT synthesize a client_model when the caller doesn't pass one.
|
||||
# A real TAA onboarding-login capture shows neither a
|
||||
# "ClientModelParams(...)" segment in keyValue nor a "client" key in
|
||||
# the JSON body when client_model is omitted — a previous version of
|
||||
# this method always synthesized f"ftv-{platform}" here, which meant
|
||||
# ClientModelParams/"client" were ALWAYS sent even when the real
|
||||
# client never sends them for this call. Only include them when the
|
||||
# caller explicitly supplies a client_model.
|
||||
resolved_client_model = client_model
|
||||
|
||||
# "os" is "API level {N}", not "Android {N}" — confirmed from a real
|
||||
# TAA capture ("os":"API level 30" for android-tv/atv-launcher,
|
||||
# api_level="30"). A previous version of this method reconstructed
|
||||
# "Android 11" from android_version, which was an unconfirmed guess
|
||||
# and is now known to be wrong.
|
||||
api_level = platform_config.get("api_level")
|
||||
os_string = f"API level {api_level}" if api_level else resolved_device_model
|
||||
|
||||
# Build keyValue string with client model if available
|
||||
key_value_parts = [IDM, APPVERSION2]
|
||||
key_value_parts = [IDM, version]
|
||||
|
||||
# Add client model if available
|
||||
# Add client model only if explicitly provided (see note above)
|
||||
if resolved_client_model:
|
||||
key_value_parts.append(f"ClientModelParams(id={resolved_client_model})")
|
||||
|
||||
key_value_parts.extend(
|
||||
[
|
||||
f"TokenChannelParams(id=Tv)",
|
||||
f"TokenDeviceParams(id={self.device_id}, model={resolved_device_model}, os={platform_config['firmware']})",
|
||||
f"TokenDeviceParams(id={self.device_id}, model={resolved_device_model}, os={os_string})",
|
||||
"DE",
|
||||
"telekom",
|
||||
]
|
||||
@@ -109,21 +145,24 @@ class Magenta2Credentials(ClientCredentials):
|
||||
|
||||
key_value = "/".join(key_value_parts)
|
||||
|
||||
# Start with template and populate fields
|
||||
# Start with template and populate fields — appVersion is set here
|
||||
# from the single version source, not baked into the template (the
|
||||
# template can't hold a platform-specific value).
|
||||
payload = TAA_REQUEST_TEMPLATE.copy()
|
||||
payload.update(
|
||||
{
|
||||
"keyValue": key_value,
|
||||
"accessToken": access_token,
|
||||
"appVersion": version,
|
||||
"device": {
|
||||
"id": self.device_id,
|
||||
"model": resolved_device_model,
|
||||
"os": platform_config["firmware"],
|
||||
"os": os_string,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Add client model if available
|
||||
# Add client model only if explicitly provided (see note above)
|
||||
if resolved_client_model:
|
||||
payload["client"] = {"model": resolved_client_model}
|
||||
|
||||
@@ -241,7 +280,7 @@ class Magenta2AuthConfig:
|
||||
self.platform_config = MAGENTA2_PLATFORMS.get(
|
||||
platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM]
|
||||
)
|
||||
self.user_agent = self.platform_config["user_agent"]
|
||||
self.user_agent = render_user_agent(self.platform, subscriber_suffix=False)
|
||||
self.timeout = 30
|
||||
self.endpoints = endpoints or {}
|
||||
self.client_model = client_model
|
||||
@@ -382,9 +421,13 @@ class Magenta2Authenticator(BaseAuthenticator):
|
||||
self._device_model,
|
||||
)
|
||||
|
||||
# Extract and cache client_id
|
||||
self._client_id = self._sam3_client_id or MAGENTA2_CLIENT_IDS.get(
|
||||
self.platform, MAGENTA2_CLIENT_IDS[DEFAULT_PLATFORM]
|
||||
# Extract and cache client_id. Pre-discovery placeholder only —
|
||||
# _configure_authenticator_from_discovery() (called by provider.py
|
||||
# right after discover_provider_config() succeeds) overwrites this
|
||||
# with the real, server-provided sam3ClientId before any actual
|
||||
# token request is made.
|
||||
self._client_id = self._sam3_client_id or MAGENTA2_LEGACY_CLIENT_IDS.get(
|
||||
self.platform, MAGENTA2_LEGACY_CLIENT_IDS[DEFAULT_PLATFORM]
|
||||
)
|
||||
|
||||
# Initialize credentials if not provided
|
||||
@@ -581,13 +624,11 @@ class Magenta2Authenticator(BaseAuthenticator):
|
||||
logger.debug(f"Token classified as USER_AUTHENTICATED (found {key} in JWT)")
|
||||
return TokenAuthLevel.USER_AUTHENTICATED
|
||||
|
||||
# Check for client credentials patterns
|
||||
client_id = claims.get("client_id", claims.get("clientId", ""))
|
||||
if client_id in MAGENTA2_CLIENT_IDS.values():
|
||||
logger.debug("Token classified as CLIENT_CREDENTIALS (known client ID)")
|
||||
return TokenAuthLevel.CLIENT_CREDENTIALS
|
||||
|
||||
# Default to client credentials for TAA flow
|
||||
# Everything else falls through to client-credentials classification
|
||||
# for the TAA flow. (A prior version checked client_id against the
|
||||
# legacy MAGENTA2_CLIENT_IDS table here, but both branches of that
|
||||
# check returned CLIENT_CREDENTIALS regardless — it was a no-op,
|
||||
# so it's been removed along with the dependency on that table.)
|
||||
logger.debug("Token classified as CLIENT_CREDENTIALS (default for TAA)")
|
||||
return TokenAuthLevel.CLIENT_CREDENTIALS
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ class AuthBridge:
|
||||
provider_config: Any,
|
||||
session_id: str,
|
||||
serial_number: str,
|
||||
user_agent_plain: str,
|
||||
user_agent_subscriber: str,
|
||||
generate_call_id, # callable() -> str
|
||||
) -> None:
|
||||
self._authenticator = authenticator
|
||||
@@ -67,6 +69,8 @@ class AuthBridge:
|
||||
self._provider_config = provider_config
|
||||
self._session_id = session_id
|
||||
self._serial_number = serial_number
|
||||
self._ua_plain = user_agent_plain
|
||||
self._ua_subscriber = user_agent_subscriber
|
||||
self._generate_call_id = generate_call_id
|
||||
|
||||
self._persona_cache: Optional[PersonaResult] = None
|
||||
@@ -187,7 +191,7 @@ class AuthBridge:
|
||||
"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"],
|
||||
"user-agent": self._ua_subscriber,
|
||||
"accept": "*/*",
|
||||
"accept-encoding": "gzip, deflate, br, zstd",
|
||||
"accept-language": "de-DE,de;q=0.9",
|
||||
@@ -200,7 +204,7 @@ class AuthBridge:
|
||||
"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"],
|
||||
"user-agent": self._ua_subscriber,
|
||||
"accept-encoding": "gzip",
|
||||
}
|
||||
|
||||
@@ -209,7 +213,7 @@ class AuthBridge:
|
||||
persona_token = self.ensure_authenticated()
|
||||
return {
|
||||
"Authorization": f"Basic {persona_token}",
|
||||
"User-Agent": self._platform_config["user_agent"],
|
||||
"User-Agent": self._ua_subscriber,
|
||||
"Accept-Encoding": "gzip",
|
||||
"CID": f"{self._session_id}::{self._generate_call_id()}",
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ from .constants import (
|
||||
DRM_SYSTEM_WIDEVINE,
|
||||
ERROR_CODES,
|
||||
MODE_LIVE,
|
||||
QUALITY_RANK,
|
||||
)
|
||||
from .endpoint_manager import EndpointManager
|
||||
from .config_models import ProviderConfig
|
||||
@@ -58,6 +57,8 @@ class ChannelManager:
|
||||
provider_config: Optional[ProviderConfig],
|
||||
auth_callback: Callable[[], str],
|
||||
build_scaled_image_url_callback: Callable[[str], Optional[str]],
|
||||
user_agent_plain: str,
|
||||
user_agent_subscriber: str,
|
||||
catchup_window: int = 4,
|
||||
):
|
||||
self._http = http_manager
|
||||
@@ -70,8 +71,19 @@ class ChannelManager:
|
||||
self._provider_config = provider_config
|
||||
self._ensure_authenticated = auth_callback
|
||||
self._build_scaled_image_url = build_scaled_image_url_callback
|
||||
self._ua_plain = user_agent_plain
|
||||
self._ua_subscriber = user_agent_subscriber
|
||||
self.catchup_window = catchup_window
|
||||
|
||||
# Special-rights rules (e.g. "uhd" -> liveTvOption False) from the
|
||||
# manifest, used by _is_station_playable() to filter stations a
|
||||
# non-managed device/account isn't allowed to play live.
|
||||
self._special_rights = (
|
||||
provider_config.manifest.special_rights_profiles
|
||||
if provider_config and provider_config.manifest
|
||||
else {}
|
||||
)
|
||||
|
||||
# Populated on first get_channels() call
|
||||
self._cached_channels: Optional[List[StreamingChannel]] = None
|
||||
|
||||
@@ -106,7 +118,7 @@ class ChannelManager:
|
||||
try:
|
||||
import uuid
|
||||
cid = f"{self._session_id}::{str(uuid.uuid4())}"
|
||||
user_agent = self._platform_config["user_agent"]
|
||||
user_agent = self._ua_subscriber
|
||||
|
||||
# ── Step 1: distribution rights ──────────────────────────────────
|
||||
rights_url = (
|
||||
@@ -170,6 +182,14 @@ class ChannelManager:
|
||||
try:
|
||||
station_id = self._extract_station_id(tp_ch.station_id)
|
||||
meta = self.station_metadata.get(station_id, {})
|
||||
|
||||
if not self._is_station_playable(meta.get("special_rights_profile")):
|
||||
logger.debug(
|
||||
f"Skipping {station_id} — special-rights blocked "
|
||||
f"(profile={meta.get('special_rights_profile')})"
|
||||
)
|
||||
continue
|
||||
|
||||
name = meta.get("title") or tp_ch.station_id
|
||||
logo_url = meta.get("logo_url")
|
||||
quality = meta.get("quality")
|
||||
@@ -362,7 +382,7 @@ class ChannelManager:
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {entitlement_token}",
|
||||
"User-Agent": self._platform_config["user_agent"],
|
||||
"User-Agent": self._ua_subscriber,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
@@ -567,17 +587,20 @@ class ChannelManager:
|
||||
break
|
||||
|
||||
channel_number = entry.get("dt$displayChannelNumber")
|
||||
special_rights_profile = (
|
||||
station_info.get("dt$clientData", {}) or {}
|
||||
).get("specialRightsProfile")
|
||||
|
||||
existing = metadata.get(station_id)
|
||||
if not existing or QUALITY_RANK.get(quality, 1) > QUALITY_RANK.get(
|
||||
existing["quality"], 1
|
||||
):
|
||||
if station_id in metadata:
|
||||
logger.debug(f"Duplicate station entry: {station_id}")
|
||||
else:
|
||||
metadata[station_id] = {
|
||||
"title": title,
|
||||
"logo_url": logo_url,
|
||||
"quality": quality,
|
||||
"channel_number": channel_number,
|
||||
"playback_id": playback_id,
|
||||
"special_rights_profile": special_rights_profile,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug(f"_fetch_station_metadata: skipping entry: {exc}")
|
||||
@@ -591,9 +614,36 @@ class ChannelManager:
|
||||
|
||||
return metadata
|
||||
|
||||
def _is_station_playable(self, profile: Optional[str]) -> bool:
|
||||
"""
|
||||
Return True if the manifest allows live playback of a station
|
||||
carrying this specialRightsProfile tag (e.g. "uhd").
|
||||
|
||||
`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
|
||||
only carries distributionRightIds into `extra`, never dt$clientData,
|
||||
so reading it from there would always fail open (nothing filtered).
|
||||
|
||||
A station is blocked only if:
|
||||
1. It carries a specialRightsProfile tag, AND
|
||||
2. The manifest has a rule for that tag, AND
|
||||
3. The rule says liveTvOption == False.
|
||||
|
||||
No rule for a given tag means unrestricted (e.g. this is the case
|
||||
for UHD/Sky/DAZN on the MagentaTV One manifest).
|
||||
"""
|
||||
if not profile:
|
||||
return True
|
||||
rule = self._special_rights.get(profile)
|
||||
if rule is None:
|
||||
return True
|
||||
return rule.live_tv_option
|
||||
|
||||
def _get_api_headers(self, require_auth: bool = False) -> Dict[str, str]:
|
||||
headers = {
|
||||
"User-Agent": self._platform_config["user_agent"],
|
||||
"User-Agent": self._ua_subscriber if require_auth else self._ua_plain,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@@ -6,17 +6,33 @@ from ...base.utils.logger import logger
|
||||
|
||||
|
||||
def extract_and_release_lock(
|
||||
smil_content: str, http_manager: HTTPManager, client_id: str, user_agent: str
|
||||
smil_content: str,
|
||||
http_manager: HTTPManager,
|
||||
device_id: str,
|
||||
session_id: str,
|
||||
call_id_callback,
|
||||
user_agent: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Extract concurrency lock from SMIL and immediately release it
|
||||
Returns True if lock was found and released, False otherwise
|
||||
Extract concurrency lock from SMIL and immediately release it.
|
||||
Returns True if lock was found and released, False otherwise.
|
||||
|
||||
We release the lock immediately rather than holding it during playback,
|
||||
so we never call the /update endpoint the real client calls every
|
||||
~30s (per updateLockInterval in the SMIL response) to keep a lock alive.
|
||||
Implementing /update would require the playback manager to own the lock
|
||||
lifecycle — out of scope here; noted for future work.
|
||||
|
||||
Args:
|
||||
smil_content: SMIL XML content
|
||||
http_manager: HTTP manager for making requests
|
||||
client_id: The client ID used in SMIL request (will be formatted as player_{client_id})
|
||||
user_agent: Platform-specific user agent
|
||||
smil_content: SMIL XML content.
|
||||
http_manager: HTTP manager for making requests.
|
||||
device_id: Persisted device UUID (same value used in the SMIL
|
||||
request's clientId param). Sent as player_{device_id} — matching
|
||||
format confirmed from a real ATV One capture of both the SMIL
|
||||
request and this unlock request.
|
||||
session_id: Session UUID, used in the CID header.
|
||||
call_id_callback: Callable () -> str returning a fresh UUID per request.
|
||||
user_agent: Platform-specific (subscriber-suffixed) user agent.
|
||||
"""
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -49,14 +65,17 @@ def extract_and_release_lock(
|
||||
logger.debug("SMIL doesn't contain complete concurrency lock")
|
||||
return False
|
||||
|
||||
# Build release URL with the same client_id formatted as player_{client_id}
|
||||
# player_{device_id} — same format the real client uses for both
|
||||
# the SMIL clientId param and this unlock call's _clientId param.
|
||||
client_id = f"player_{device_id}"
|
||||
cid = f"{session_id}::{call_id_callback()}"
|
||||
|
||||
base_url = lock_params["concurrencyServiceUrl"].rstrip("/") + "/web/Concurrency/unlock"
|
||||
formatted_client_id = f"player_{client_id}"
|
||||
|
||||
params = {
|
||||
"schema": "1.0",
|
||||
"form": "json",
|
||||
"_clientId": formatted_client_id, # Use player_{smil_client_id}
|
||||
"_clientId": client_id,
|
||||
"_id": lock_params["lockId"],
|
||||
"_sequenceToken": urllib.parse.quote(lock_params["lockSequenceToken"]),
|
||||
"_encryptedLock": urllib.parse.quote(lock_params["lock"]),
|
||||
@@ -66,15 +85,25 @@ def extract_and_release_lock(
|
||||
release_url = f"{base_url}?{param_string}"
|
||||
|
||||
logger.debug(
|
||||
f"Releasing concurrency lock: {lock_params['lockId']} with client: {formatted_client_id}"
|
||||
f"Releasing concurrency lock: {lock_params['lockId']} with client: {client_id}"
|
||||
)
|
||||
|
||||
headers = {
|
||||
"User-Agent": user_agent, # Use platform-specific user agent
|
||||
"User-Agent": user_agent,
|
||||
"Accept": "application/json",
|
||||
"CID": cid,
|
||||
# Cookie names embed the client ID — confirmed from a real
|
||||
# unlock-request capture. Not optional; the server appears to
|
||||
# read the lock/sequence data from these cookies as well as
|
||||
# (or instead of) the query params.
|
||||
"Cookie": (
|
||||
f"LockId_{client_id}="
|
||||
f"id={lock_params['lockId']}"
|
||||
f"&sequenceToken={lock_params['lockSequenceToken']}; "
|
||||
f"LockEncr_{client_id}={lock_params['lock']}"
|
||||
),
|
||||
}
|
||||
|
||||
# Release the lock immediately
|
||||
# Release the lock immediately
|
||||
response = http_manager.get(
|
||||
release_url, operation="concurrency_unlock", headers=headers, timeout=10
|
||||
@@ -91,7 +120,7 @@ def extract_and_release_lock(
|
||||
response_data = response.json()
|
||||
if "unlockResponse" in response_data:
|
||||
logger.info(
|
||||
f"✓ Concurrency lock released successfully with client: {formatted_client_id}"
|
||||
f"✓ Concurrency lock released successfully with client: {client_id}"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
@@ -110,4 +139,4 @@ def extract_and_release_lock(
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error releasing concurrency lock: {e}")
|
||||
return False
|
||||
return False
|
||||
@@ -11,7 +11,7 @@ class BootstrapConfig:
|
||||
|
||||
client_model: str
|
||||
device_model: str
|
||||
subscriber_type: str = "FTV_OTT_DT" # resolved from platform via SUBSCRIBER_TYPES
|
||||
subscriber_type: str = "FTV_OTT_DT" # resolved from MAGENTA2_PLATFORMS[platform] in from_api_response()
|
||||
sam3_client_id: Optional[str] = None
|
||||
taa_url: Optional[str] = None
|
||||
device_tokens_url: Optional[str] = None
|
||||
@@ -30,16 +30,35 @@ class BootstrapConfig:
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, bootstrap_data: Dict[str, Any], platform: str) -> "BootstrapConfig":
|
||||
"""Create BootstrapConfig from API response"""
|
||||
from .constants import SUBSCRIBER_TYPES
|
||||
"""
|
||||
Create BootstrapConfig from API response.
|
||||
|
||||
Raises:
|
||||
ValueError: if clientModel, deviceModel or sam3ClientId is missing
|
||||
from baseSettings. This is intentional — a bootstrap response
|
||||
missing these fields means the server didn't recognize us as
|
||||
a real client, and silently falling back (e.g. to the legacy
|
||||
MAGENTA2_LEGACY_CLIENT_IDS table) would mask that. Do not add
|
||||
a fallback here; let discovery fail loudly instead.
|
||||
"""
|
||||
from .constants import MAGENTA2_PLATFORMS
|
||||
|
||||
base_settings = bootstrap_data.get("baseSettings", {})
|
||||
dcm_settings = bootstrap_data.get("dcm", {})
|
||||
|
||||
required = ("clientModel", "deviceModel", "sam3ClientId")
|
||||
missing = [k for k in required if not base_settings.get(k)]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Bootstrap response missing required fields: {missing}"
|
||||
)
|
||||
|
||||
platform_cfg = MAGENTA2_PLATFORMS.get(platform, {})
|
||||
|
||||
return cls(
|
||||
client_model=base_settings.get("clientModel", f"ftv-{platform}"),
|
||||
device_model=base_settings.get("deviceModel", f"{platform.upper()}_FTV"),
|
||||
subscriber_type=SUBSCRIBER_TYPES.get(platform, "FTV_OTT_DT"),
|
||||
client_model=base_settings["clientModel"],
|
||||
device_model=base_settings["deviceModel"],
|
||||
subscriber_type=platform_cfg.get("subscriber_type", "FTV_OTT_DT"),
|
||||
sam3_client_id=base_settings.get("sam3ClientId"),
|
||||
taa_url=base_settings.get("taaUrl"),
|
||||
device_tokens_url=base_settings.get("deviceTokensUrl"),
|
||||
@@ -263,6 +282,52 @@ class TvHubConfig:
|
||||
return self.base_urls.get("UnstructuredGrid")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpecialRightsRule:
|
||||
"""
|
||||
One entry from manifest.mpx.specialRightsProfiles.clientData — governs
|
||||
whether a station tagged with this profile name (e.g. "uhd") may be
|
||||
played live, timeshifted, caught up, or recorded on this device/account.
|
||||
"""
|
||||
|
||||
name: str
|
||||
live_tv_option: bool
|
||||
timeshift_option: bool
|
||||
catchup_option: bool
|
||||
npvr_option: bool
|
||||
manage_option: bool
|
||||
|
||||
|
||||
def _parse_special_rights_profiles(manifest_data: Dict[str, Any]) -> Dict[str, "SpecialRightsRule"]:
|
||||
"""
|
||||
Parse manifest.mpx.specialRightsProfiles.clientData into a lookup by
|
||||
profile name (e.g. "uhd"). A station with no matching entry here is
|
||||
unrestricted — see channel_manager.ChannelManager._is_station_playable.
|
||||
"""
|
||||
rules: Dict[str, SpecialRightsRule] = {}
|
||||
entries = (
|
||||
manifest_data.get("mpx", {})
|
||||
.get("specialRightsProfiles", {})
|
||||
.get("clientData", [])
|
||||
or []
|
||||
)
|
||||
for entry in entries:
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
continue
|
||||
live = entry.get("liveTv", {}) or {}
|
||||
rec = entry.get("recording", {}) or {}
|
||||
rules[name] = SpecialRightsRule(
|
||||
name=name,
|
||||
live_tv_option=bool(live.get("liveTvOption", False)),
|
||||
timeshift_option=bool(live.get("timeshiftOption", False)),
|
||||
catchup_option=bool(live.get("catchupOption", False)),
|
||||
npvr_option=bool(rec.get("npvrOption", False)),
|
||||
manage_option=bool(rec.get("manageOption", False)),
|
||||
)
|
||||
return rules
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManifestConfig:
|
||||
"""Complete configuration from manifest discovery"""
|
||||
@@ -273,6 +338,7 @@ class ManifestConfig:
|
||||
image_config: ImageConfig
|
||||
youbora_config: Dict[str, Any] = field(default_factory=dict)
|
||||
npvr_config: Dict[str, Any] = field(default_factory=dict)
|
||||
special_rights_profiles: Dict[str, SpecialRightsRule] = field(default_factory=dict)
|
||||
raw_data: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
@@ -285,6 +351,7 @@ class ManifestConfig:
|
||||
image_config=ImageConfig.from_manifest_data(manifest_data),
|
||||
youbora_config=manifest_data.get("youbora", {}),
|
||||
npvr_config=manifest_data.get("npvr", {}),
|
||||
special_rights_profiles=_parse_special_rights_profiles(manifest_data),
|
||||
raw_data=manifest_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# streaming_providers/providers/magenta2/constants.py
|
||||
from typing import Optional
|
||||
|
||||
# ============================================================================
|
||||
# Magenta2 Configuration
|
||||
# ============================================================================
|
||||
@@ -12,107 +14,129 @@ DEFAULT_COUNTRY = "de"
|
||||
MAGENTA2_LOGO = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e4/Magenta_TV_Logo_2024.svg/2339px-Magenta_TV_Logo_2024.svg.png"
|
||||
|
||||
# Platform configuration
|
||||
#
|
||||
# Each entry describes only what's needed to build the bootstrap/manifest
|
||||
# request and the User-Agent. Everything else (client_model, device_model,
|
||||
# sam3_client_id, all endpoint URLs) comes from the server's bootstrap/
|
||||
# manifest response — see config_models.BootstrapConfig / ManifestConfig.
|
||||
#
|
||||
# Versions below are confirmed from real-device captures/logs:
|
||||
# - android-tv / atv-launcher: 3.180.7748 (Sebastian's current AndroidTV,
|
||||
# shape confirmed against real AndroidTV request logs). SMIL, DRM and
|
||||
# concurrency request *shapes* were only ever captured on atv-launcher
|
||||
# (v3.136.4682) — carried over to android-tv on the assumption the
|
||||
# selector/concurrency service doesn't vary by config_group. Treat that
|
||||
# part as unverified for android-tv specifically until confirmed by a
|
||||
# live-play capture.
|
||||
# - web: 2.128.5 (MacBook web client capture).
|
||||
DEFAULT_PLATFORM = "android-tv"
|
||||
|
||||
MAGENTA2_PLATFORMS = {
|
||||
"web": {
|
||||
"device_name": "Web Browser",
|
||||
"firmware": "Chrome 120",
|
||||
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"terminal_type": "WEB",
|
||||
# TAA-specific device identification
|
||||
"taa_device_model": "Web Browser",
|
||||
"taa_os": "Chrome 120",
|
||||
},
|
||||
"android-tv": {
|
||||
"device_name": "Android TV",
|
||||
"firmware": "Android 11",
|
||||
"user_agent": "Dalvik/2.1.0 (Linux; U; Android 11; SHIELD Android TV Build/RQ1A.210105.003) ((2.00T_ATV::3.134.4462::mdarcy::))",
|
||||
"terminal_type": "ATV_ANDROIDTV",
|
||||
# TAA-specific device identification (API level format required)
|
||||
"taa_device_model": "SHIELD Android TV",
|
||||
"taa_os": "API level 30",
|
||||
"config_group": "atv-androidtv",
|
||||
"subscriber_type": "FTV_OTT_DT",
|
||||
"application_model": "DT:ATV-AndroidTV",
|
||||
"api_level": "30",
|
||||
"android_version": "11",
|
||||
"device_name": "SHIELD Android TV",
|
||||
"build_id": "RQ1A.210105.003",
|
||||
"build_flavor": "mdarcy",
|
||||
"version": "3.180.7748",
|
||||
"ua_template_plain": (
|
||||
"Dalvik/2.1.0 (Linux; U; Android {android_version}; "
|
||||
"{device_name} Build/{build_id}) "
|
||||
"((2.00T_ATV::{version}::{build_flavor}::))"
|
||||
),
|
||||
"ua_template_subscriber": (
|
||||
"Dalvik/2.1.0 (Linux; U; Android {android_version}; "
|
||||
"{device_name} Build/{build_id}) "
|
||||
"((2.00T_ATV::{version}::{build_flavor}::{subscriber_type}))"
|
||||
),
|
||||
},
|
||||
|
||||
"atv-launcher": {
|
||||
"device_name": "MagentaTV Stick",
|
||||
"firmware": "Android 11",
|
||||
"user_agent": "Mozilla/5.0 (Linux; Android 11; AFTS Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
||||
"terminal_type": "ATV_LAUNCHER",
|
||||
# TAA-specific device identification
|
||||
"taa_device_model": "MagentaTV Stick",
|
||||
"taa_os": "API level 30",
|
||||
"config_group": "atv-launcher",
|
||||
"subscriber_type": "FTV_OTT_DT",
|
||||
"application_model": "DT:ATV-Launcher",
|
||||
"api_level": "30",
|
||||
"android_version": "11",
|
||||
"device_name": "SHIELD Android TV",
|
||||
"build_id": "RQ1A.210105.003",
|
||||
"build_flavor": "mdarcy",
|
||||
"version": "3.180.7748",
|
||||
"ua_template_plain": (
|
||||
"Dalvik/2.1.0 (Linux; U; Android {android_version}; "
|
||||
"{device_name} Build/{build_id}) "
|
||||
"((2.00T_ATV::{version}::{build_flavor}::))"
|
||||
),
|
||||
"ua_template_subscriber": (
|
||||
"Dalvik/2.1.0 (Linux; U; Android {android_version}; "
|
||||
"{device_name} Build/{build_id}) "
|
||||
"((2.00T_ATV::{version}::{build_flavor}::{subscriber_type}))"
|
||||
),
|
||||
},
|
||||
"android-mobile": {
|
||||
"device_name": "Android Mobile",
|
||||
"firmware": "Android 13",
|
||||
"user_agent": "Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36",
|
||||
"terminal_type": "ANDROID_MOBILE",
|
||||
# TAA-specific device identification
|
||||
"taa_device_model": "Android Mobile",
|
||||
"taa_os": "API level 33",
|
||||
},
|
||||
"ios": {
|
||||
"device_name": "iPhone",
|
||||
"firmware": "iOS 15",
|
||||
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1",
|
||||
"terminal_type": "IOS",
|
||||
# TAA-specific device identification
|
||||
"taa_device_model": "iPhone",
|
||||
"taa_os": "iOS 15.0",
|
||||
|
||||
"web": {
|
||||
"config_group": "web-mtv",
|
||||
"subscriber_type": "FTV_OTT_DT",
|
||||
"application_model": "DT:WEB",
|
||||
"api_level": "0",
|
||||
"android_version": "", # unused for web
|
||||
"device_name": "",
|
||||
"build_id": "",
|
||||
"build_flavor": "",
|
||||
"version": "2.128.5",
|
||||
"ua_template_plain": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
"ua_template_subscriber": None, # web UA never has a suffix
|
||||
},
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Manifest Request Configuration
|
||||
# ============================================================================
|
||||
|
||||
# App identification for manifest requests
|
||||
MAGENTA2_APP_NAME = "MagentaTV"
|
||||
MAGENTA2_APP_VERSION = "104180"
|
||||
MAGENTA2_RUNTIME_VERSION = "1"
|
||||
def render_user_agent(
|
||||
platform: str,
|
||||
subscriber_suffix: bool = False,
|
||||
version_override: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Render the User-Agent string for a platform.
|
||||
|
||||
# Model names for manifest requests (different from device models!)
|
||||
MANIFEST_MODEL_MAPPINGS = {
|
||||
"web": "DT:WEB",
|
||||
"android-tv": "DT:ATV-AndroidTV",
|
||||
"atv-launcher": "DT:ATV-Launcher",
|
||||
"android-mobile": "DT:Android-Mobile",
|
||||
"ios": "DT:IOS",
|
||||
}
|
||||
Args:
|
||||
platform: key into MAGENTA2_PLATFORMS.
|
||||
subscriber_suffix: if True, use the template with the subscriber_type
|
||||
suffix (SMIL and DRM requests). If False, use the plain template
|
||||
(bootstrap, manifest, DCM requests).
|
||||
version_override: optional; replaces the platform's configured version.
|
||||
|
||||
# Firmware strings for manifest requests
|
||||
MANIFEST_FIRMWARE_MAPPINGS = {
|
||||
"web": "Chrome 120",
|
||||
"android-tv": "API level 30",
|
||||
"atv-launcher": "API level 30",
|
||||
"android-mobile": "API level 33",
|
||||
"ios": "iOS 15.0",
|
||||
}
|
||||
Raises:
|
||||
ValueError: if `platform` is not a known key in MAGENTA2_PLATFORMS.
|
||||
"""
|
||||
cfg = MAGENTA2_PLATFORMS.get(platform)
|
||||
if cfg is None:
|
||||
raise ValueError(f"Unknown platform: {platform}")
|
||||
|
||||
# Also update the fallback mappings:
|
||||
CLIENT_MODEL_MAPPINGS = {
|
||||
"web": "ftv-web",
|
||||
"android-tv": "ftv-androidtv",
|
||||
"atv-launcher": "ftv-androidtv",
|
||||
"android-mobile": "ftv-android",
|
||||
"ios": "ftv-ios",
|
||||
}
|
||||
template = (
|
||||
cfg["ua_template_subscriber"] if subscriber_suffix
|
||||
else cfg["ua_template_plain"]
|
||||
)
|
||||
if template is None:
|
||||
template = cfg["ua_template_plain"]
|
||||
|
||||
DEVICE_MODEL_MAPPINGS = {
|
||||
"web": "WebBrowser_FTV",
|
||||
"android-tv": "AndroidTV_FTV",
|
||||
"atv-launcher": "AndroidTV_FTV",
|
||||
"android-mobile": "AndroidMobile_FTV",
|
||||
"ios": "iOS_FTV",
|
||||
}
|
||||
version = version_override or cfg["version"]
|
||||
|
||||
# And update subscriber types if needed:
|
||||
SUBSCRIBER_TYPES = {
|
||||
"web": "WEB_OTT_DT",
|
||||
"android-tv": "FTV_OTT_DT",
|
||||
"atv-launcher": "FTV_OTT_DT", # Same as android-tv
|
||||
"android-mobile": "MOB_OTT_DT", # Different for mobile
|
||||
"ios": "IOS_OTT_DT",
|
||||
}
|
||||
if "{" not in template:
|
||||
return template
|
||||
|
||||
return template.format(
|
||||
android_version=cfg["android_version"],
|
||||
device_name=cfg["device_name"],
|
||||
build_id=cfg["build_id"],
|
||||
build_flavor=cfg["build_flavor"],
|
||||
version=version,
|
||||
subscriber_type=cfg["subscriber_type"],
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# API Configuration - MINIMAL HARDCODING
|
||||
@@ -120,8 +144,8 @@ SUBSCRIBER_TYPES = {
|
||||
|
||||
# Only bootstrap endpoint is hardcoded - everything else discovered dynamically
|
||||
MAGENTA2_BASE_URL = "https://prod.dcm.telekom-dienste.de/v1"
|
||||
MAGENTA2_BOOTSTRAP_URL = MAGENTA2_BASE_URL + "/settings/{terminal_type}/bootstrap"
|
||||
MAGENTA2_MANIFEST_URL = MAGENTA2_BASE_URL + "/settings/{terminal_type}/manifest"
|
||||
MAGENTA2_BOOTSTRAP_URL = MAGENTA2_BASE_URL + "/settings/{config_group}/bootstrap"
|
||||
MAGENTA2_MANIFEST_URL = MAGENTA2_BASE_URL + "/settings/{config_group}/manifest"
|
||||
|
||||
# Fallback endpoints if discovery fails
|
||||
MAGENTA2_FALLBACK_ENDPOINTS = {
|
||||
@@ -141,7 +165,10 @@ MAGENTA2_FALLBACK_ACCOUNT_URI = "http://access.auth.theplatform.com/data/Account
|
||||
|
||||
# Application identifiers
|
||||
IDM = "TDGIDM"
|
||||
APPVERSION2 = "3.134.4462"
|
||||
# NOTE: no separate app-version constant. auth.py::to_taa_payload reads
|
||||
# MAGENTA2_PLATFORMS[platform]["version"] directly, confirmed by a real TAA
|
||||
# capture to be identical to the version embedded in the User-Agent — a
|
||||
# previously separate APPVERSION2 constant here could drift from that value.
|
||||
|
||||
SSO_URL = "https://ssom.magentatv.de/login"
|
||||
# SSO User Agent
|
||||
@@ -151,16 +178,19 @@ SSO_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, lik
|
||||
# OAuth2 Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Client IDs for different platforms
|
||||
MAGENTA2_CLIENT_IDS = {
|
||||
# Legacy UUIDv4 client IDs.
|
||||
#
|
||||
# Used ONLY as a last-resort fallback if the bootstrap response fails to
|
||||
# return a sam3ClientId — which in practice never happens, because a missing
|
||||
# sam3ClientId already makes BootstrapConfig.from_api_response raise (see
|
||||
# config_models.py), so discovery itself fails before this table would be
|
||||
# consulted. Modern clients read sam3ClientId from the bootstrap response
|
||||
# (baseSettings.sam3ClientId), not from this table.
|
||||
MAGENTA2_LEGACY_CLIENT_IDS = {
|
||||
"web": "709115c2-f87e-4bad-9b94-28ac08d72cd9",
|
||||
"android-tv": "05f5f3df-1130-4707-a761-c04d0c50b7f2",
|
||||
"ios": "21218403-52ec-4a65-abf4-f36a0eadd631",
|
||||
}
|
||||
|
||||
# Client ID used for SMIL/selector manifest requests
|
||||
SMIL_CLIENT_ID = "a8198f31-b406-4177-8dee-f6216c356c75"
|
||||
|
||||
# OAuth2 scopes
|
||||
MAGENTA2_OAUTH_SCOPE = "openid profile offline_access tvhubs"
|
||||
|
||||
@@ -256,10 +286,13 @@ ERROR_CODES = {
|
||||
# TAA Configuration
|
||||
# ============================================================================
|
||||
|
||||
# TAA request template
|
||||
# TAA request template.
|
||||
# NOTE: no "appVersion" key here — it must vary by platform (each platform
|
||||
# has its own version in MAGENTA2_PLATFORMS), so auth.py::to_taa_payload
|
||||
# sets it dynamically from platform_config["version"] instead of baking in
|
||||
# a single fixed value that couldn't be correct for every platform.
|
||||
TAA_REQUEST_TEMPLATE = {
|
||||
"accessTokenSource": IDM,
|
||||
"appVersion": APPVERSION2,
|
||||
"channel": {"id": "Tv"},
|
||||
"natco": "DE",
|
||||
"type": "telekom",
|
||||
@@ -287,8 +320,15 @@ QUALITY_FALLBACK: dict = {
|
||||
"SD": ["SD"],
|
||||
}
|
||||
|
||||
# Integer rank per quality label — used for fast channel deduplication when
|
||||
# multiple entries share the same display number (keep the highest-ranked one).
|
||||
# Integer rank per quality label.
|
||||
#
|
||||
# NOTE: no longer used for live-channel dedup in channel_manager.py —
|
||||
# SD/HD/UHD variants of a station have distinct station_ids in the entitled-
|
||||
# channels feed, so a rank-based "keep the highest quality" merge in
|
||||
# _fetch_station_metadata never actually fired on real data and has been
|
||||
# removed (first entry wins for a genuinely duplicate station_id, with a
|
||||
# debug log). Kept here because it may still be read by VOD quality
|
||||
# selection alongside QUALITY_FALLBACK above — grep before deleting.
|
||||
QUALITY_RANK: dict = {
|
||||
"SD": 1,
|
||||
"HD": 2,
|
||||
@@ -302,9 +342,9 @@ QUALITY_RANK: dict = {
|
||||
# ============================================================================
|
||||
|
||||
# tvhubs base URL template — {client_model} is resolved at runtime from
|
||||
# CLIENT_MODEL_MAPPINGS. This is a fallback; the live value should come
|
||||
# from the manifest's tv_hubs.base_urls["ftv"] (or equivalent) via
|
||||
# ProviderConfig.get_resolved_tvhub_url().
|
||||
# BootstrapConfig.client_model (server-provided). This URL is only a
|
||||
# fallback; the live value should come from the manifest's
|
||||
# tv_hubs.base_urls["ftv"] (or equivalent) via ProviderConfig.get_resolved_tvhub_url().
|
||||
TVHUBS_BASE_URL = "https://tvhubs.t-online.de/v3/{client_model}"
|
||||
|
||||
# Flex IDs for the VOD catalogue.
|
||||
|
||||
@@ -11,8 +11,9 @@ from .constants import (
|
||||
DEFAULT_REQUEST_TIMEOUT,
|
||||
MAGENTA2_BOOTSTRAP_URL,
|
||||
MAGENTA2_MANIFEST_URL,
|
||||
MAGENTA2_PLATFORMS,
|
||||
OPENID_CONFIG_CACHE_DURATION,
|
||||
SUBSCRIBER_TYPES,
|
||||
render_user_agent,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,19 +25,26 @@ class DiscoveryService:
|
||||
def __init__(
|
||||
self,
|
||||
platform: str,
|
||||
terminal_type: str,
|
||||
device_id: str,
|
||||
session_id: str,
|
||||
http_manager: HTTPManager,
|
||||
proxy_config: Optional[ProxyConfig] = None,
|
||||
user_type: str = "normal",
|
||||
):
|
||||
cfg = MAGENTA2_PLATFORMS[platform]
|
||||
self.platform = platform
|
||||
self.terminal_type = terminal_type
|
||||
self._config_group = cfg["config_group"]
|
||||
self._subscriber_type = cfg["subscriber_type"]
|
||||
self._application_model = cfg["application_model"]
|
||||
self._api_level = cfg["api_level"]
|
||||
self._version = cfg["version"]
|
||||
self._ua_plain = render_user_agent(platform, subscriber_suffix=False)
|
||||
self._ua_subscriber = render_user_agent(platform, subscriber_suffix=True)
|
||||
self.device_id = device_id
|
||||
self.session_id = session_id
|
||||
self.http_manager = http_manager
|
||||
self.proxy_config = proxy_config
|
||||
self.subscriber_type = SUBSCRIBER_TYPES.get(platform, "FTV_OTT_DT")
|
||||
self._user_type = user_type
|
||||
|
||||
# Cache storage
|
||||
self._bootstrap_config: Optional[BootstrapConfig] = None
|
||||
@@ -118,16 +126,26 @@ class DiscoveryService:
|
||||
try:
|
||||
logger.info("Discovering bootstrap configuration")
|
||||
|
||||
terminal_type = self.terminal_type.lower().replace("_", "-")
|
||||
url = MAGENTA2_BOOTSTRAP_URL.format(terminal_type=terminal_type)
|
||||
url = MAGENTA2_BOOTSTRAP_URL.format(config_group=self._config_group)
|
||||
|
||||
# Superset of the modern web-client shape (deviceId, portal,
|
||||
# subscriberType, $redirect, sid — from MacBook web capture) and
|
||||
# the legacy/ATV shape (applicationModel, version — confirmed
|
||||
# against real AndroidTV request logs, v3.180.7748). deviceModel
|
||||
# is intentionally NOT sent on bootstrap: it's absent from every
|
||||
# modern-shape capture we have (web and ATV alike) and only
|
||||
# appears on the legacy ATV bootstrap shape.
|
||||
params = {
|
||||
"deviceid": self.device_id,
|
||||
"sid": self.session_id,
|
||||
"deviceId": self.device_id,
|
||||
"portal": "release",
|
||||
"subscriberType": self._subscriber_type,
|
||||
"$redirect": "false",
|
||||
"sid": self.session_id,
|
||||
"applicationModel": self._application_model,
|
||||
"version": self._version,
|
||||
}
|
||||
|
||||
headers = self._get_dcm_headers()
|
||||
headers = self._get_dcm_headers(subscriber_suffix=False)
|
||||
|
||||
response = self.http_manager.get(
|
||||
url,
|
||||
@@ -183,42 +201,45 @@ class DiscoveryService:
|
||||
try:
|
||||
logger.info("Discovering manifest configuration")
|
||||
|
||||
# Manifest discovery only runs after a successful bootstrap — it
|
||||
# needs deviceModel from the bootstrap response.
|
||||
if not self._bootstrap_config:
|
||||
logger.error("Cannot discover manifest: no bootstrap config available")
|
||||
return None
|
||||
|
||||
# Priority 1: Use dcm.manifestBaseUrl from bootstrap
|
||||
if self._bootstrap_config and self._bootstrap_config.manifest_base_url:
|
||||
terminal_type = self.terminal_type.lower().replace("_", "-")
|
||||
if self._bootstrap_config.manifest_base_url:
|
||||
manifest_url = self._bootstrap_config.manifest_base_url.replace(
|
||||
"{configGroupId}", terminal_type
|
||||
"{configGroupId}", self._config_group
|
||||
)
|
||||
logger.debug(f"Using bootstrap manifestBaseUrl: {manifest_url}")
|
||||
|
||||
# Priority 2: Fallback to hardcoded manifest URL
|
||||
else:
|
||||
terminal_type = self.terminal_type.lower().replace("_", "-")
|
||||
manifest_url = MAGENTA2_MANIFEST_URL.format(terminal_type=terminal_type)
|
||||
manifest_url = MAGENTA2_MANIFEST_URL.format(config_group=self._config_group)
|
||||
logger.debug(f"Using fallback manifest URL: {manifest_url}")
|
||||
|
||||
# Build correct manifest parameters
|
||||
from .constants import (
|
||||
MAGENTA2_APP_NAME,
|
||||
MAGENTA2_APP_VERSION,
|
||||
MAGENTA2_RUNTIME_VERSION,
|
||||
MANIFEST_FIRMWARE_MAPPINGS,
|
||||
MANIFEST_MODEL_MAPPINGS,
|
||||
)
|
||||
|
||||
# Superset of the modern web-client shape (deviceId, deviceModel,
|
||||
# portal, subscriberType, $redirect, sid — from MacBook web
|
||||
# capture) and the legacy/ATV shape (applicationModel, version,
|
||||
# apiLevel, userType — confirmed against real AndroidTV request
|
||||
# logs, v3.180.7748).
|
||||
params = {
|
||||
"model": MANIFEST_MODEL_MAPPINGS.get(self.platform, "DT:ATV-AndroidTV"),
|
||||
"deviceId": self.device_id,
|
||||
"appname": MAGENTA2_APP_NAME,
|
||||
"appVersion": MAGENTA2_APP_VERSION,
|
||||
"firmware": MANIFEST_FIRMWARE_MAPPINGS.get(self.platform, "API level 30"),
|
||||
"runtimeVersion": MAGENTA2_RUNTIME_VERSION,
|
||||
"duid": self.device_id, # Same as deviceId
|
||||
"portal": "release",
|
||||
"subscriberType": self._subscriber_type,
|
||||
"$redirect": "false",
|
||||
"sid": self.session_id,
|
||||
"deviceModel": self._bootstrap_config.device_model,
|
||||
"applicationModel": self._application_model,
|
||||
"version": self._version,
|
||||
"apiLevel": self._api_level,
|
||||
"userType": self._user_type,
|
||||
}
|
||||
|
||||
logger.debug(f"Manifest request params: {params}")
|
||||
|
||||
headers = self._get_dcm_headers()
|
||||
headers = self._get_dcm_headers(subscriber_suffix=False)
|
||||
|
||||
response = self.http_manager.get(
|
||||
manifest_url,
|
||||
@@ -315,18 +336,14 @@ class DiscoveryService:
|
||||
self._last_openid = None
|
||||
return None
|
||||
|
||||
def _get_dcm_headers(self) -> Dict[str, str]:
|
||||
"""Get headers for DCM requests"""
|
||||
from .constants import DEFAULT_PLATFORM, MAGENTA2_PLATFORMS
|
||||
|
||||
platform_config = MAGENTA2_PLATFORMS.get(
|
||||
self.platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM]
|
||||
)
|
||||
|
||||
def _get_dcm_headers(self, subscriber_suffix: bool = False) -> Dict[str, str]:
|
||||
"""Get headers for DCM requests (bootstrap/manifest)."""
|
||||
ua = self._ua_subscriber if subscriber_suffix else self._ua_plain
|
||||
return {
|
||||
"User-Agent": platform_config["user_agent"],
|
||||
"User-Agent": ua,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"x-dt-session-id": self.session_id,
|
||||
"x-dt-call-id": self._generate_call_id(),
|
||||
}
|
||||
@@ -395,4 +412,4 @@ class DiscoveryService:
|
||||
self._last_bootstrap = None
|
||||
self._last_manifest = None
|
||||
self._last_openid = None
|
||||
logger.info("Discovery cache cleared")
|
||||
logger.info("Discovery cache cleared")
|
||||
@@ -74,7 +74,21 @@ class PlaybackManager:
|
||||
provider_config:
|
||||
ProviderConfig after discovery.
|
||||
platform_config:
|
||||
Platform-specific dict from MAGENTA2_PLATFORMS (user_agent, etc.).
|
||||
Platform-specific dict from MAGENTA2_PLATFORMS. Kept for any fields
|
||||
not covered by user_agent_subscriber (subscriber_type, etc.) — no
|
||||
longer used for platform_config["user_agent"], which doesn't exist
|
||||
anymore; use user_agent_subscriber instead.
|
||||
user_agent_subscriber:
|
||||
Rendered UA (with subscriber_type suffix) — used for the live-DRM
|
||||
fast path in get_drm(), matching what SmilManager uses for its own
|
||||
(SMIL/VOD) DRM path.
|
||||
session_id / call_id_callback:
|
||||
Needed so the live-DRM fast path can send a CID header
|
||||
("{session_id}::{call_id}"), matching the SMIL DRM path (see
|
||||
smil_manager.get_drm) and confirmed against a real ATV widevine
|
||||
capture, which shows only CID — no separate session-id header.
|
||||
call_id_callback is optional — if omitted, CID is left off entirely
|
||||
(better than sending a malformed header).
|
||||
auth_callback:
|
||||
Callable[[], str] — returns a valid persona token (Basic-auth value).
|
||||
recording_url_cache:
|
||||
@@ -91,6 +105,9 @@ class PlaybackManager:
|
||||
platform_config: Dict,
|
||||
auth_callback: Callable[[], str],
|
||||
recording_url_cache: Dict[str, str],
|
||||
user_agent_subscriber: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
call_id_callback: Optional[Callable[[], str]] = None,
|
||||
):
|
||||
self._channel_manager = channel_manager
|
||||
self._smil_manager = smil_manager
|
||||
@@ -99,6 +116,9 @@ class PlaybackManager:
|
||||
self._platform_config = platform_config
|
||||
self._ensure_authenticated = auth_callback
|
||||
self._recording_url_cache = recording_url_cache
|
||||
self._ua_subscriber = user_agent_subscriber
|
||||
self._session_id = session_id
|
||||
self._call_id = call_id_callback
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Public API #
|
||||
@@ -340,10 +360,15 @@ class PlaybackManager:
|
||||
persona_jwt=raw_jwt,
|
||||
account_uri=account_uri,
|
||||
)
|
||||
call_id = self._call_id() if self._call_id else None
|
||||
return [
|
||||
build_widevine_drm_config(
|
||||
licence_url=licence_url,
|
||||
user_agent=self._platform_config["user_agent"],
|
||||
# Subscriber-suffixed UA — matches what SmilManager.get_drm
|
||||
# sends for the SMIL/VOD DRM path.
|
||||
user_agent=self._ua_subscriber,
|
||||
session_id=self._session_id,
|
||||
call_id=call_id,
|
||||
)
|
||||
]
|
||||
except Exception as exc:
|
||||
|
||||
@@ -15,7 +15,7 @@ that delegates to the three domain managers:
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Tuple, cast
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Tuple, cast, Union
|
||||
from urllib.parse import quote
|
||||
|
||||
from ...base.models import DRMConfig, StreamingChannel, Event
|
||||
@@ -41,10 +41,11 @@ from .constants import (
|
||||
DEFAULT_MAX_RETRIES,
|
||||
DEFAULT_PLATFORM,
|
||||
DEFAULT_REQUEST_TIMEOUT,
|
||||
MAGENTA2_CLIENT_IDS,
|
||||
MAGENTA2_LEGACY_CLIENT_IDS,
|
||||
MAGENTA2_LOGO,
|
||||
MAGENTA2_PLATFORMS,
|
||||
SUPPORTED_COUNTRIES,
|
||||
render_user_agent,
|
||||
)
|
||||
from .discovery import DiscoveryService
|
||||
from .endpoint_manager import EndpointManager
|
||||
@@ -78,15 +79,33 @@ class Magenta2Provider(StreamingProvider):
|
||||
)
|
||||
|
||||
self.platform = platform
|
||||
self.platform_config = MAGENTA2_PLATFORMS.get(
|
||||
platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM]
|
||||
)
|
||||
self.terminal_type = self.platform_config["terminal_type"]
|
||||
if platform not in MAGENTA2_PLATFORMS:
|
||||
raise ValueError(
|
||||
f"Unknown platform '{platform}'. Supported: {list(MAGENTA2_PLATFORMS.keys())}"
|
||||
)
|
||||
self.platform_config = MAGENTA2_PLATFORMS[platform]
|
||||
self.user_agent_plain = render_user_agent(platform, subscriber_suffix=False)
|
||||
self.user_agent_subscriber = render_user_agent(platform, subscriber_suffix=True)
|
||||
|
||||
# Stable UUIDs for the lifetime of this provider instance.
|
||||
self.session_id = self._generate_uuid()
|
||||
self.device_id = self._generate_uuid()
|
||||
self.serial_number = self._generate_uuid()
|
||||
# session_id is fresh per process launch (matches the real client).
|
||||
self.session_id = str(uuid.uuid1())
|
||||
|
||||
# device_id: read from the SAME persisted source TokenFlowManager
|
||||
# already uses (SessionManager.get_device_id), which itself
|
||||
# generates-and-persists on first call. Do NOT generate a second,
|
||||
# independent device_id here — doing so would make discovery/SMIL
|
||||
# send one device_id while the TAA/yo_digital token flow uses a
|
||||
# different one, and the server would see two "devices" for one
|
||||
# installation. get_device_id() currently persists a uuid4 — keep
|
||||
# that format; do not switch to uuid1 for this value.
|
||||
self.device_id = self.settings_manager.session_manager.get_device_id(
|
||||
self.provider_name, self.country
|
||||
)
|
||||
|
||||
# serial_number has no existing persisted home in SessionManager, so
|
||||
# it gets its own small persisted key here (mirrors get_device_id's
|
||||
# load-or-generate pattern without changing the shared SessionManager).
|
||||
self.serial_number = self._get_or_create_serial_number()
|
||||
|
||||
# ── Proxy ────────────────────────────────────────────────────────────
|
||||
self.proxy_config = (
|
||||
@@ -103,7 +122,7 @@ class Magenta2Provider(StreamingProvider):
|
||||
self.http_manager = HTTPManagerFactory.create_for_provider(
|
||||
provider_name="magenta2",
|
||||
proxy_config=self.proxy_config,
|
||||
user_agent=self.platform_config["user_agent"],
|
||||
user_agent=self.user_agent_plain,
|
||||
timeout=DEFAULT_REQUEST_TIMEOUT,
|
||||
max_retries=DEFAULT_MAX_RETRIES,
|
||||
)
|
||||
@@ -111,7 +130,6 @@ class Magenta2Provider(StreamingProvider):
|
||||
# ── Discovery service ────────────────────────────────────────────────
|
||||
self.discovery_service = DiscoveryService(
|
||||
platform=platform,
|
||||
terminal_type=self.terminal_type,
|
||||
device_id=self.device_id,
|
||||
session_id=self.session_id,
|
||||
http_manager=self.http_manager,
|
||||
@@ -127,12 +145,19 @@ class Magenta2Provider(StreamingProvider):
|
||||
self.provider_config = cast(ProviderConfig, cast(object, None))
|
||||
|
||||
# ── Authenticator (minimal config; updated after discovery) ──────────
|
||||
fallback_client_id = MAGENTA2_CLIENT_IDS.get(
|
||||
platform, MAGENTA2_CLIENT_IDS[DEFAULT_PLATFORM]
|
||||
# This placeholder client_id is overwritten by the real,
|
||||
# server-provided sam3ClientId in _configure_authenticator_from_discovery()
|
||||
# below, which always runs before __init__ returns (or __init__ raises
|
||||
# and construction never completes — see _perform_configuration_discovery).
|
||||
# It is unreachable in normal operation; it only matters for the
|
||||
# handful of authenticator calls made before discovery finishes, none
|
||||
# of which occur in this constructor.
|
||||
fallback_client_id = MAGENTA2_LEGACY_CLIENT_IDS.get(
|
||||
platform, MAGENTA2_LEGACY_CLIENT_IDS[DEFAULT_PLATFORM]
|
||||
)
|
||||
|
||||
if username and password:
|
||||
credentials: Magenta2Credentials | Magenta2UserCredentials = (
|
||||
credentials: Union[Magenta2Credentials, Magenta2UserCredentials] = (
|
||||
Magenta2UserCredentials(
|
||||
client_id=fallback_client_id,
|
||||
platform=platform,
|
||||
@@ -221,6 +246,9 @@ class Magenta2Provider(StreamingProvider):
|
||||
http_manager=self.http_manager,
|
||||
provider_name=self.provider_name,
|
||||
session_id=self.session_id,
|
||||
device_id=self.device_id,
|
||||
user_agent_plain=self.user_agent_plain,
|
||||
user_agent_subscriber=self.user_agent_subscriber,
|
||||
call_id_callback=self._generate_call_id,
|
||||
auth_callback=self._ensure_authenticated,
|
||||
platform_config=self.platform_config,
|
||||
@@ -241,6 +269,8 @@ class Magenta2Provider(StreamingProvider):
|
||||
provider_config=self.provider_config,
|
||||
auth_callback=self._ensure_authenticated,
|
||||
build_scaled_image_url_callback=self._build_scaled_image_url,
|
||||
user_agent_plain=self.user_agent_plain,
|
||||
user_agent_subscriber=self.user_agent_subscriber,
|
||||
catchup_window=self.catchup_window,
|
||||
)
|
||||
logger.info("✓ ChannelManager initialized")
|
||||
@@ -253,6 +283,9 @@ class Magenta2Provider(StreamingProvider):
|
||||
platform_config=self.platform_config,
|
||||
auth_callback=self._ensure_authenticated,
|
||||
recording_url_cache=self._recording_url_cache,
|
||||
user_agent_subscriber=self.user_agent_subscriber,
|
||||
session_id=self.session_id,
|
||||
call_id_callback=self._generate_call_id,
|
||||
)
|
||||
logger.info("✓ PlaybackManager initialized")
|
||||
|
||||
@@ -279,6 +312,8 @@ class Magenta2Provider(StreamingProvider):
|
||||
provider_config=self.provider_config,
|
||||
session_id=self.session_id,
|
||||
serial_number=self.serial_number,
|
||||
user_agent_plain=self.user_agent_plain,
|
||||
user_agent_subscriber=self.user_agent_subscriber,
|
||||
generate_call_id=self._generate_call_id,
|
||||
)
|
||||
|
||||
@@ -295,6 +330,31 @@ class Magenta2Provider(StreamingProvider):
|
||||
def _generate_call_id(self) -> str:
|
||||
return self._generate_uuid()
|
||||
|
||||
def _get_or_create_serial_number(self) -> str:
|
||||
"""
|
||||
Return a stable serial number for this installation, persisted
|
||||
across runs.
|
||||
|
||||
SessionManager has a dedicated get_device_id() that TokenFlowManager
|
||||
also relies on, but no equivalent for serial_number, so this mirrors
|
||||
the same load-or-generate pattern locally (same session_data blob,
|
||||
different key) rather than adding a new public method to the shared
|
||||
SessionManager.
|
||||
"""
|
||||
session_manager = self.settings_manager.session_manager
|
||||
session_data = session_manager.load_session(self.provider_name, self.country) or {}
|
||||
|
||||
serial_number = session_data.get("serial_number")
|
||||
if not serial_number:
|
||||
serial_number = str(uuid.uuid4())
|
||||
session_data["serial_number"] = serial_number
|
||||
session_manager.save_session(self.provider_name, session_data, self.country)
|
||||
logger.info(f"Generated new serial number: {serial_number}")
|
||||
else:
|
||||
logger.debug(f"Using existing serial number: {serial_number}")
|
||||
|
||||
return serial_number
|
||||
|
||||
def _load_proxy_from_manager(self, config_dir: Optional[str]) -> Optional[ProxyConfig]:
|
||||
try:
|
||||
proxy_manager = ProxyConfigManager(config_dir)
|
||||
@@ -630,7 +690,7 @@ class Magenta2Provider(StreamingProvider):
|
||||
|
||||
def _get_dcm_headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"User-Agent": self.platform_config["user_agent"],
|
||||
"User-Agent": self.user_agent_plain,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"x-dt-session-id": self.session_id,
|
||||
@@ -639,7 +699,7 @@ class Magenta2Provider(StreamingProvider):
|
||||
|
||||
def _get_api_headers(self, require_auth: bool = False) -> Dict[str, str]:
|
||||
headers: Dict[str, str] = {
|
||||
"User-Agent": self.platform_config["user_agent"],
|
||||
"User-Agent": self.user_agent_subscriber if require_auth else self.user_agent_plain,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ from .constants import (
|
||||
DEFAULT_REQUEST_TIMEOUT,
|
||||
MAGENTA2_FALLBACK_ACCOUNT_URI,
|
||||
SMIL_CACHE_DURATION,
|
||||
SMIL_CLIENT_ID,
|
||||
VOD_PREFIX_EPISODE,
|
||||
VOD_PREFIX_MOVIE_MV,
|
||||
VOD_PREFIX_MOVIE_SH,
|
||||
@@ -65,11 +64,27 @@ class SmilManager:
|
||||
http_manager: HTTPManager instance from the parent provider.
|
||||
provider_name: Provider identifier string (e.g. ``"magenta2"``).
|
||||
session_id: Stable session UUID used in ``cid`` correlation header.
|
||||
device_id: Persisted device UUID (from
|
||||
``session_manager.get_device_id``). Used as
|
||||
``player_{device_id}`` in the SMIL ``clientId``
|
||||
query param and passed through to the
|
||||
concurrency-unlock call, which must use the same
|
||||
value the SMIL request used.
|
||||
user_agent_plain: Rendered UA without the subscriber_type suffix.
|
||||
Currently unused directly by this class (SMIL and
|
||||
DRM requests are always subscriber-suffixed per
|
||||
capture) but kept for parity with DiscoveryService
|
||||
and in case a future endpoint needs it.
|
||||
user_agent_subscriber: Rendered UA with the subscriber_type suffix —
|
||||
used for SMIL, DRM and concurrency-unlock requests,
|
||||
matching the real client's captured headers.
|
||||
call_id_callback: Callable ``() -> str`` returning a fresh UUID per request.
|
||||
auth_callback: Callable ``() -> str`` returning the current
|
||||
Base64-encoded persona token.
|
||||
platform_config: Platform dict from ``MAGENTA2_PLATFORMS`` — supplies
|
||||
User-Agent and DRM request headers.
|
||||
platform_config: Platform dict from ``MAGENTA2_PLATFORMS``. Kept for
|
||||
any remaining legacy fields; no longer used for
|
||||
platform_config["user_agent"], which doesn't exist
|
||||
anymore — use user_agent_subscriber instead.
|
||||
endpoint_manager: EndpointManager for selector / widevine endpoint lookup.
|
||||
provider_config: ProviderConfig — account PID and account URI.
|
||||
vod_manager: Optional VodManager — required only for GN id resolution.
|
||||
@@ -81,6 +96,9 @@ class SmilManager:
|
||||
http_manager,
|
||||
provider_name: str,
|
||||
session_id: str,
|
||||
device_id: str,
|
||||
user_agent_plain: str,
|
||||
user_agent_subscriber: str,
|
||||
call_id_callback,
|
||||
auth_callback,
|
||||
platform_config: Dict,
|
||||
@@ -92,6 +110,9 @@ class SmilManager:
|
||||
self._http = http_manager
|
||||
self._provider = provider_name
|
||||
self._session_id = session_id
|
||||
self._device_id = device_id
|
||||
self._ua_plain = user_agent_plain
|
||||
self._ua_subscriber = user_agent_subscriber
|
||||
self._call_id = call_id_callback
|
||||
self._auth = auth_callback
|
||||
self._platform_config = platform_config
|
||||
@@ -217,6 +238,9 @@ class SmilManager:
|
||||
f"account={quote(account_uri, safe='')}"
|
||||
)
|
||||
|
||||
call_id = self._call_id()
|
||||
cid = f"{self._session_id}::{call_id}"
|
||||
|
||||
drm_config = DRMConfig(
|
||||
system=DRMSystem.WIDEVINE,
|
||||
priority=1,
|
||||
@@ -226,8 +250,9 @@ class SmilManager:
|
||||
server_certificate=None,
|
||||
req_headers=json.dumps(
|
||||
{
|
||||
"User-Agent": self._platform_config["user_agent"],
|
||||
"User-Agent": self._ua_subscriber,
|
||||
"Content-Type": "application/octet-stream",
|
||||
"CID": cid,
|
||||
}
|
||||
),
|
||||
use_http_get_request=False,
|
||||
@@ -463,8 +488,15 @@ class SmilManager:
|
||||
logger.error(f"{self._provider}: No persona token for SMIL request")
|
||||
return None
|
||||
|
||||
cid = f"{self._session_id}::{self._call_id()}"
|
||||
smil_params = f"?format=SMIL&formats=MPEG-DASH&tracking=true&cid={cid}"
|
||||
call_id = self._call_id()
|
||||
cid = f"{self._session_id}::{call_id}"
|
||||
smil_params = (
|
||||
f"?format=smil"
|
||||
f"&formats=MPEG-DASH"
|
||||
f"&tracking=true"
|
||||
f"&clientId=player_{self._device_id}"
|
||||
f"&cid={cid}"
|
||||
)
|
||||
|
||||
if smil_base_url:
|
||||
smil_url = f"{smil_base_url.split('?')[0]}{smil_params}"
|
||||
@@ -487,8 +519,8 @@ class SmilManager:
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Basic {persona_token}",
|
||||
"User-Agent": self._platform_config["user_agent"],
|
||||
"Accept": "application/smil+xml, application/xml;q=0.9, */*;q=0.8",
|
||||
"User-Agent": self._ua_subscriber,
|
||||
"CID": cid,
|
||||
}
|
||||
|
||||
logger.debug(f"{self._provider}: SMIL URL: {smil_url}")
|
||||
@@ -528,8 +560,10 @@ class SmilManager:
|
||||
extract_and_release_lock(
|
||||
smil_content,
|
||||
self._http,
|
||||
client_id=SMIL_CLIENT_ID,
|
||||
user_agent=self._platform_config["user_agent"],
|
||||
device_id=self._device_id,
|
||||
session_id=self._session_id,
|
||||
call_id_callback=self._call_id,
|
||||
user_agent=self._ua_subscriber,
|
||||
)
|
||||
|
||||
return smil_content
|
||||
|
||||
@@ -72,7 +72,6 @@ from ...base.models.quality import Quality
|
||||
|
||||
from .constants import (
|
||||
QUALITY_FALLBACK,
|
||||
SUBSCRIBER_TYPES,
|
||||
TVHUBS_BASE_URL,
|
||||
VOD_DEFAULT_PAGE_SIZE,
|
||||
VOD_FLEX_ID_DETAILS,
|
||||
@@ -222,9 +221,14 @@ class VodManager:
|
||||
# request via _playback_params(). Not available from bootstrap because it
|
||||
# is portal-specific metadata returned by the server, not a device identity.
|
||||
self._partner_map_id: Optional[str] = None
|
||||
# subscriber_type is not in bootstrap; derive from platform once at init.
|
||||
_platform = getattr(bootstrap, "platform", "")
|
||||
self._subscriber_type: str = SUBSCRIBER_TYPES.get(_platform, "FTV_OTT_DT")
|
||||
# subscriber_type comes straight from BootstrapConfig.subscriber_type,
|
||||
# which config_models.BootstrapConfig.from_api_response populates from
|
||||
# MAGENTA2_PLATFORMS[platform]["subscriber_type"] at discovery time.
|
||||
# (Previously this tried bootstrap.platform, which BootstrapConfig
|
||||
# never actually had -- getattr silently returned "" and the
|
||||
# SUBSCRIBER_TYPES lookup always fell through to its "FTV_OTT_DT"
|
||||
# default regardless of platform. This reads the real value now.)
|
||||
self._subscriber_type: str = getattr(bootstrap, "subscriber_type", None) or "FTV_OTT_DT"
|
||||
|
||||
# Node registry: opaque content_id → (fetch_url, extra_params)
|
||||
# Populated when lanes/series/seasons are discovered so that
|
||||
|
||||
Reference in New Issue
Block a user