Files
script.service.ultimate/lib/streaming_providers/providers/magenta2/provider.py
T
2026-09-29 21:15:40 +02:00

1364 lines
60 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# streaming_providers/providers/magenta2/provider.py
# -*- coding: utf-8 -*-
"""
Magenta2 streaming provider.
This module contains only lifecycle, authentication, and the thin public API
that delegates to the domain managers:
ChannelManager – channel discovery, entitlement, streaming-data population
PlaybackManager – manifest / DRM routing (live fast-path + SMIL fallback)
VodManager – VOD catalogue browsing
RecordingsManager – nPVR recordings (list / delete / manifest)
TimersManager – nPVR scheduled recordings (timer CRUD)
SmilManager – SMIL-based manifest and DRM for VOD / recordings
Magenta2EpgManager – EPG grid + programme-details (ThePlatform API)
"""
import hashlib
import random
import threading
import uuid
from datetime import datetime
from typing import Any, ClassVar, Dict, List, NamedTuple, Optional, Tuple, cast, Union
from urllib.parse import quote
from ...base.auth.session_manager import SessionManager
from ...base.models import DRMConfig, StreamingChannel, Event
from ...base.models.auth import AuthState
from ...base.models.epg_models import EPGEntry, EPGProgramDetails
from ...base.models.proxy_models import ProxyConfig
from ...base.network import HTTPManagerFactory, ProxyConfigManager
from ...base.provider import StreamingProvider
from ...base.utils.logger import logger
from .recordings_manager import RecordingsManager
from .timers_manager import TimersManager
from .smil_manager import SmilManager
from .vod_manager import VodManager
from .auth import Magenta2Authenticator, Magenta2Credentials, Magenta2UserCredentials
from .channel_manager import ChannelManager
from .epg_manager import Magenta2EpgManager
from .playback_manager import PlaybackManager
from .config_models import ProviderConfig
from .constants import (
CONTENT_TYPE_LIVE,
DEFAULT_COUNTRY,
DEFAULT_EPG_WINDOW_HOURS,
DEFAULT_MAX_RETRIES,
DEFAULT_PLATFORM,
DEFAULT_REQUEST_TIMEOUT,
MAGENTA2_LEGACY_CLIENT_IDS,
MAGENTA2_LOGO,
MAGENTA2_PLATFORMS,
SUPPORTED_COUNTRIES,
render_user_agent,
)
from . import constants as _constants
from .discovery import DiscoveryService
from .endpoint_manager import EndpointManager
from .models import Magenta2PlaybackRestrictedException # noqa: F401 – re-exported
from .auth_bridge import AuthBridge
# drm_variant vocabulary as documented by ProviderCatchupMixin — used only
# for the epg_id misroute warning in get_catchup_manifest().
_KNOWN_DRM_VARIANTS = ("auto", "software", "hardware")
# Key fragments that mark a dict value as secret-looking, for the
# _redact() safety net in debug_authentication().
_SENSITIVE_KEY_PARTS = (
"token", "secret", "password", "authorization", "jwt",
"cookie", "credential", "bearer", "key",
)
# Values that can ONLY be a legacy positional content_type argument, never a
# legitimate drm_variant — no DRM-variant vocabulary contains content-type
# tokens, so matching these has no false positives. Derived from every
# CONTENT_TYPE_* constant in .constants so a missed spelling is impossible.
_MISROUTE_DRM_VARIANT_TOKENS = frozenset(
v.lower()
for k, v in vars(_constants).items()
if k.startswith("CONTENT_TYPE_") and isinstance(v, str)
)
class _ManagerBundle(NamedTuple):
"""A fully-constructed domain-manager set, published by a single
attribute assignment so readers can never observe a mixed generation."""
vod: VodManager
recordings: RecordingsManager
timers: TimersManager
smil: SmilManager
channel: ChannelManager
playback: PlaybackManager
epg: Magenta2EpgManager
class Magenta2Provider(StreamingProvider):
"""
Magenta2 streaming provider implementation with enhanced dynamic discovery.
"""
# ── Static metadata (StreamingProvider ClassVar contract) ──────────────
# Readable without instantiation; the @property twins below return these
# so there is exactly one source of truth per value.
PROVIDER_LABEL: ClassVar[str] = "Magenta TV 2.0"
PROVIDER_LOGO: ClassVar[str] = MAGENTA2_LOGO
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["network_based"]
# RHS resolves to the module-level import from .constants (the name is
# not yet in the class namespace at this point); list() copies it so the
# ClassVar never aliases the mutable constants-module object.
SUPPORTED_COUNTRIES: ClassVar[List[str]] = list(SUPPORTED_COUNTRIES)
implements_timers: ClassVar[bool] = True
def __init__(
self,
country: str = DEFAULT_COUNTRY,
platform: str = DEFAULT_PLATFORM,
config_dir: Optional[str] = None,
proxy_config: Optional[ProxyConfig] = None,
proxy_url: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
):
super().__init__(country=country)
if country not in SUPPORTED_COUNTRIES:
raise ValueError(
f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}"
)
self.platform = platform
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)
# session_id is fresh per process launch (matches the real client's
# UUIDv1 wire format) but uses a RANDOM node so the host/container
# MAC address is never embedded and never leaves the machine in
# x-dt-session-id. Bit 40 (the multicast bit, RFC 4122) marks the
# node as random rather than a hardware address. Accepted risk: a
# real device's MAC has that bit clear, so a node-checking server
# could tell — but fabricating a unicast MAC risks colliding with a
# real device's address, which is worse.
self.session_id = str(uuid.uuid1(node=random.getrandbits(48) | (1 << 40)))
# ── Session persistence (shared session.json) ─────────────────────────
# The provider needs only the SessionManager half of SettingsManager,
# so construct it directly — the same way SettingsManager itself does
# (SessionManager(config_dir_path)). The same `config_dir` is passed
# to Magenta2Authenticator below, so this class, the authenticator
# and its TokenFlowManager all resolve the SAME session.json through
# the same VFS paths (one device_id / serial_number per installation).
self._session_manager: SessionManager = SessionManager(config_dir)
# 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._session_manager.get_device_id(
self.provider_name, self.country
)
# serial_number: same load-or-generate pattern, its own persisted key.
self.serial_number = self._get_or_create_serial_number()
# ── Proxy ────────────────────────────────────────────────────────────
self.proxy_config = (
proxy_config
or (ProxyConfig.from_url(proxy_url) if proxy_url else None)
or self._load_proxy_from_manager(config_dir)
)
if self.proxy_config:
logger.info("Using proxy configuration for Magenta2")
else:
logger.debug("No proxy configuration found for Magenta2")
# ── HTTP manager ─────────────────────────────────────────────────────
self.http_manager = HTTPManagerFactory.create_for_provider(
provider_name="magenta2",
proxy_config=self.proxy_config,
user_agent=self.user_agent_plain,
timeout=DEFAULT_REQUEST_TIMEOUT,
max_retries=DEFAULT_MAX_RETRIES,
)
# ── Discovery service ────────────────────────────────────────────────
self.discovery_service = DiscoveryService(
platform=platform,
device_id=self.device_id,
session_id=self.session_id,
http_manager=self.http_manager,
proxy_config=self.proxy_config,
)
# Serializes configuration refreshes end-to-end (whole
# refresh_configuration body). Readers never take it: the manager
# bundle is published by a single attribute assignment.
self._refresh_lock = threading.Lock()
# Both are assigned concrete values by _perform_configuration_discovery()
# before __init__ returns — or discovery raises and construction never
# completes (fail-hard: there is no fallback configuration). cast(None)
# is a typed sentinel so the class-level annotations stay non-Optional
# and other methods see EndpointManager / ProviderConfig directly.
self.endpoint_manager = cast(EndpointManager, cast(object, None))
self.provider_config = cast(ProviderConfig, cast(object, None))
# ── Authenticator (minimal config; updated after discovery) ──────────
# This placeholder client_id is replaced by the server-provided
# sam3ClientId in _configure_authenticator_from_discovery() below —
# UNLESS the discovered config lacks a sam3_client_id, in which case
# this legacy fallback persists (and discovery has already warned
# that the config is incomplete). It also covers the window before
# discovery completes; no authenticator call that depends on the
# real client_id happens inside this constructor.
fallback_client_id = MAGENTA2_LEGACY_CLIENT_IDS.get(
platform, MAGENTA2_LEGACY_CLIENT_IDS[DEFAULT_PLATFORM]
)
if username and password:
credentials: Union[Magenta2Credentials, Magenta2UserCredentials] = (
Magenta2UserCredentials(
client_id=fallback_client_id,
platform=platform,
country=country,
device_id=self.device_id,
username=username,
password=password,
)
)
logger.info("Using user credentials for authentication")
else:
credentials = Magenta2Credentials(
client_id=fallback_client_id,
platform=platform,
country=country,
device_id=self.device_id,
)
logger.info("Using client credentials for authentication")
self.authenticator = Magenta2Authenticator(
country=country,
platform=platform,
config_dir=config_dir,
http_manager=self.http_manager,
credentials=credentials,
endpoints={},
client_model=f"ftv-{platform}",
device_model=f"{platform.upper()}_FTV",
sam3_client_id=fallback_client_id,
session_id=self.session_id,
device_id=self.device_id,
provider_config=None,
)
# ── Configuration discovery (fail-hard) ──────────────────────────────
# _perform_configuration_discovery() logs and re-raises on failure;
# construction never completes with a half-configured provider.
self._perform_configuration_discovery()
# ── Update authenticator with discovered config ───────────────────────
self._configure_authenticator_from_discovery(self.provider_config, self.endpoint_manager)
# ── recording content_id → manifest_script; shared with PlaybackManager ──
# Survives refresh_configuration() rebuilds (playback cache, not config state).
self._recording_url_cache: Dict[str, str] = {}
# ── Auth bridge ───────────────────────────────────────────────────────
# Constructed BEFORE the domain managers: the managers receive
# callbacks (_ensure_authenticated, _vod_auth_headers,
# _pvr_auth_headers) that dereference self._auth, and all of
# AuthBridge's dependencies exist right after discovery.
self.device_token = None
self._auth = AuthBridge(
authenticator=self.authenticator,
provider_name=self.provider_name,
country=self.country,
platform=self.platform,
platform_config=self.platform_config,
provider_config=self.provider_config,
session_id=self.session_id,
serial_number=self.serial_number,
user_agent_plain=self.user_agent_plain,
user_agent_subscriber=self.user_agent_subscriber,
generate_call_id=self._generate_call_id,
)
# ── Domain managers: build fully, publish in ONE assignment ──────────
# _construct_domain_managers() assigns nothing to self; the single
# assignment below is the atomic publish. Readers snapshot
# self._managers (see the accessor properties and the multi-manager
# methods) and so never observe a mixed generation.
self._managers: _ManagerBundle = self._construct_domain_managers(
self.endpoint_manager, self.provider_config
)
logger.info("Magenta2 provider initialization completed successfully")
# ------------------------------------------------------------------ #
# Manager accessors (single-attribute bundle) #
# ------------------------------------------------------------------ #
# self._managers is the ONLY published manager state; it is swapped in
# a single assignment, which is atomic under the GIL. These read-only
# properties return non-Optional types. Methods that touch MORE than
# one manager must snapshot the bundle once instead of using several
# properties in sequence (see get_manifest / get_drm /
# get_catchup_manifest / get_epg_grid).
@property
def _vod_manager(self) -> VodManager:
return self._managers.vod
@property
def _recordings_manager(self) -> RecordingsManager:
return self._managers.recordings
@property
def _timers_manager(self) -> TimersManager:
return self._managers.timers
@property
def _smil_manager(self) -> SmilManager:
return self._managers.smil
@property
def _channel_manager(self) -> ChannelManager:
return self._managers.channel
@property
def _playback_manager(self) -> PlaybackManager:
return self._managers.playback
@property
def _epg_manager(self) -> Magenta2EpgManager:
return self._managers.epg
# ------------------------------------------------------------------ #
# Static / utility #
# ------------------------------------------------------------------ #
@staticmethod
def _generate_uuid() -> str:
return str(uuid.uuid4())
def _generate_call_id(self) -> str:
return self._generate_uuid()
@staticmethod
def _token_fingerprint(token: str) -> str:
"""Non-reversible correlation handle for secrets (sha256, 12 hex chars)."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:12]
@classmethod
def _redact(cls, value: Any, _key: str = "") -> Any:
"""
Recursively replace string values under secret-looking keys with
length + fingerprint. Over-redacts on purpose. This is a safety net
for unaudited third-party dict output, NOT a substitute for auditing
the sources — name-based redaction cannot catch secrets stored
under innocent keys.
"""
if isinstance(value, dict):
return {k: cls._redact(v, str(k)) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [cls._redact(v, _key) for v in value]
if isinstance(value, str) and any(p in _key.lower() for p in _SENSITIVE_KEY_PARTS):
return f"<redacted len={len(value)} fp={cls._token_fingerprint(value)}>"
return value
def _get_or_create_serial_number(self) -> str:
"""
Return a stable serial number for this installation, persisted
across runs (mirrors SessionManager.get_device_id's load-or-generate
pattern without adding a new public method to the shared
SessionManager).
NOTE: this is a load-modify-save on the shared session.json. Within
one process it is safe (sequential init). Across processes sharing a
config dir there is a first-run race — the same one
SessionManager.get_device_id already has. The proper fix is a file
lock or an atomic get-or-create in SessionManager, covering both.
SessionManager's own save_* methods are merge-safe (verified: every
one loads-or-{} before writing), so later token writes will NOT drop
this key; only a direct save_session() caller passing a non-loaded
blob could.
"""
session_data = self._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
self._session_manager.save_session(
self.provider_name, session_data, self.country
)
logger.debug(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)
return proxy_manager.get_proxy_config("magenta2", self.country)
except Exception as e:
logger.warning(f"Could not load proxy from ProxyConfigManager: {e}")
return None
# ------------------------------------------------------------------ #
# Provider properties #
# ------------------------------------------------------------------ #
@property
def provider_name(self) -> str:
return "magenta2"
@property
def provider_label(self) -> str:
return self.PROVIDER_LABEL
@property
def provider_logo(self) -> str:
return self.PROVIDER_LOGO
@property
def uses_dynamic_manifests(self) -> bool:
return False
@property
def epg_window(self) -> Tuple[int, int]:
"""Return EPG window as (past_days, future_days)."""
return 7, 13 # 7 days past, 13 days future
@property
def implements_recordings(self) -> bool:
return True
@property
def catchup_window(self) -> int:
"""
Catchup window in HOURS — the ProviderCatchupMixin contract
(validate_catchup_request computes max_age = catchup_window * 3600).
Ground truth from the station feed: dt$catchupOptions.cacheDuration
is "PT4H" (recordingPolicies.catchup.expirationOffset = 14400 s) on
every observed station, so 4 is correct.
"""
return 4
@property
def supported_auth_types(self) -> List[str]:
return list(self.SUPPORTED_AUTH_TYPES)
@property
def primary_token_scope(self) -> Optional[str]:
return "persona"
@property
def token_scopes(self) -> List[str]:
return ["yo_digital", "tvhubs", "taa", "persona"]
# ------------------------------------------------------------------ #
# Configuration discovery #
# ------------------------------------------------------------------ #
def _perform_configuration_discovery(self) -> None:
"""
Run discovery and initialise EndpointManager.
Fail-hard contract: on success, sets both self.provider_config and
self.endpoint_manager from the live discovery result. On failure,
logs and re-raises — __init__ does not complete. There is
deliberately no fallback configuration: without the manifest there
is no client_id, device token or endpoints, so a degraded instance
would fail on every subsequent operation anyway.
"""
logger.info("Performing Magenta2 configuration discovery")
try:
self.provider_config = self.discovery_service.discover_provider_config()
if not self.provider_config or not self.provider_config.is_complete:
# Incomplete configs are still usable for the parts that were
# discovered. refresh_configuration() is deliberately
# stricter: it would replace a known-good config.
logger.warning("Configuration discovery incomplete, some features may not work")
self.endpoint_manager = EndpointManager(self.provider_config)
if self.provider_config and self.provider_config.manifest:
device_token = self.provider_config.get_device_token()
authorize_tokens_url = self.provider_config.get_authorize_tokens_url()
if device_token:
logger.info(f"✓ Device token discovered (length: {len(device_token)})")
else:
logger.warning("⚠️ No device token found in manifest")
if authorize_tokens_url:
logger.info(f"✓ Line auth endpoint discovered: {authorize_tokens_url}")
else:
logger.warning("⚠️ No authorize tokens URL found in manifest")
if self.provider_config.manifest.mpx.account_pid:
logger.info(
f"✓ MPX account PID discovered: "
f"{self.provider_config.manifest.mpx.account_pid}"
)
missing_endpoints = self.endpoint_manager.validate_critical_endpoints()
if missing_endpoints:
logger.warning(f"Missing critical endpoints: {missing_endpoints}")
else:
logger.info("All critical endpoints available")
stats = self.endpoint_manager.get_stats()
logger.info(
f"Discovery complete: {stats['dynamic_endpoints']} dynamic endpoints, "
f"{stats['fallback_endpoints']} fallback endpoints, "
f"complete: {stats['is_complete']}"
)
except Exception as e:
logger.error(f"Configuration discovery failed: {e}")
raise
def _configure_authenticator_from_discovery(
self,
cfg: ProviderConfig,
endpoint_manager: EndpointManager,
) -> None:
"""
Push discovered config values (client_id, models, device token, MPX
PID, openid, endpoints, QR URL) into the authenticator and its
TokenFlowManager. Single config-push choke point — called from
__init__ and refresh_configuration() (including the rollback path).
Parameters are passed explicitly (not read from self) so the type
checker knows they are non-None.
"""
self.authenticator.provider_config = cfg
logger.info("✓ ProviderConfig stored in authenticator")
if (
hasattr(self.authenticator, "token_flow_manager")
and self.authenticator.token_flow_manager
):
self.authenticator.token_flow_manager.provider_config = cfg
logger.info("✓ ProviderConfig stored in TokenFlowManager")
if cfg.bootstrap.sam3_client_id:
if hasattr(self.authenticator, "update_sam3_client_id"):
self.authenticator.update_sam3_client_id(cfg.bootstrap.sam3_client_id)
else:
self.authenticator._sam3_client_id = cfg.bootstrap.sam3_client_id
if self.authenticator.credentials:
self.authenticator.credentials.client_id = cfg.bootstrap.sam3_client_id
logger.debug(
f"Updated authenticator SAM3 client ID: {cfg.bootstrap.sam3_client_id}"
)
if cfg.bootstrap.client_model:
if hasattr(self.authenticator, "update_client_model"):
self.authenticator.update_client_model(cfg.bootstrap.client_model)
else:
self.authenticator._client_model = cfg.bootstrap.client_model
logger.debug(f"Updated authenticator client model: {cfg.bootstrap.client_model}")
if cfg.bootstrap.device_model:
if hasattr(self.authenticator, "update_device_model"):
self.authenticator.update_device_model(cfg.bootstrap.device_model)
else:
self.authenticator._device_model = cfg.bootstrap.device_model
logger.debug(f"Updated authenticator device model: {cfg.bootstrap.device_model}")
if cfg.manifest:
device_token = cfg.get_device_token()
authorize_tokens_url = cfg.get_authorize_tokens_url()
if device_token:
self.authenticator.set_device_token(device_token, authorize_tokens_url)
logger.debug("Device token configured in authenticator")
if cfg.manifest.mpx.account_pid:
self.authenticator.set_mpx_account_pid(cfg.manifest.mpx.account_pid)
logger.debug(f"MPX account PID configured in authenticator: {cfg.manifest.mpx.account_pid}")
if cfg.openid:
self.authenticator.set_openid_config(cfg.openid.raw_data)
all_endpoints = {
name: info.url
for name, info in endpoint_manager.get_all_endpoints().items()
}
if hasattr(self.authenticator, "update_dynamic_endpoints"):
self.authenticator.update_dynamic_endpoints(all_endpoints)
logger.info(f"✓ Updated authenticator with {len(all_endpoints)} endpoints")
elif hasattr(self.authenticator, "update_endpoints"):
self.authenticator.update_endpoints(all_endpoints)
logger.info(f"✓ Updated authenticator with {len(all_endpoints)} endpoints")
else:
logger.warning("No public method available to update endpoints")
# Single site for the QR-URL push (also covers refresh_configuration).
qr_url = endpoint_manager.get_endpoint("login_qr_code")
if qr_url and hasattr(self.authenticator, "update_sam3_qr_code_url"):
success = self.authenticator.update_sam3_qr_code_url(qr_url)
logger.info(
"✓ SAM3 client updated with QR code URL"
if success
else "✗ Failed to update SAM3 client with QR code URL"
)
def _construct_domain_managers(
self, endpoint_manager: EndpointManager, provider_config: ProviderConfig
) -> _ManagerBundle:
"""
Build a fresh manager bundle against the given config.
No instance state is mutated (no assignments to self anywhere in
this method), so a failure here leaves the previously published
bundle and config fully intact. It is NOT side-effect free in
general, though: these are third-party constructors that may perform
I/O — ChannelManager's constructor fetches station metadata
(verified against its source) — and the returned bundle shares
self._recording_url_cache with the previous generation by design.
Note on cache loss: a rebuilt ChannelManager starts with cold
_live_manifest_cache / _live_pid_cache, and its station→pid map
holds only the station-metadata (media) pids until get_channels()
re-runs and overwrites them with the entitled release pids. Callers
that need warm caches should follow the publish with a
get_channels() warmup (see refresh_configuration).
"""
vod = VodManager(
http_manager=self.http_manager,
provider_name=self.provider_name,
bootstrap=endpoint_manager.config.bootstrap,
provider_config=endpoint_manager.config,
session_id=self.session_id,
serial_number=self.serial_number,
auth_headers_callback=self._vod_auth_headers,
)
logger.debug("VodManager constructed")
recordings = RecordingsManager(
http_manager=self.http_manager,
provider_name=self.provider_name,
provider_config=endpoint_manager.config,
auth_headers_callback=self._pvr_auth_headers,
)
logger.debug("RecordingsManager constructed")
timers = TimersManager(
http_manager=self.http_manager,
provider_name=self.provider_name,
provider_config=endpoint_manager.config,
auth_headers_callback=self._pvr_auth_headers,
)
logger.debug("TimersManager constructed")
smil = SmilManager(
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,
endpoint_manager=endpoint_manager,
provider_config=endpoint_manager.config,
vod_manager=vod,
)
logger.debug("SmilManager constructed")
channel = ChannelManager(
http_manager=self.http_manager,
provider_name=self.provider_name,
country=self.country,
platform_config=self.platform_config,
session_id=self.session_id,
serial_number=self.serial_number,
endpoint_manager=endpoint_manager,
provider_config=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.debug("ChannelManager constructed")
playback = PlaybackManager(
channel_manager=channel,
smil_manager=smil,
endpoint_manager=endpoint_manager,
provider_config=provider_config,
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.debug("PlaybackManager constructed")
epg = Magenta2EpgManager(
endpoint_manager=endpoint_manager,
provider_config=endpoint_manager.config,
http_manager=self.http_manager,
authenticator=self.authenticator,
fetch_details=False, # ← Don't fetch details on schedule grid
default_past_days=7,
default_future_days=13,
)
logger.debug("EPG Manager constructed")
return _ManagerBundle(
vod=vod,
recordings=recordings,
timers=timers,
smil=smil,
channel=channel,
playback=playback,
epg=epg,
)
@staticmethod
def _retire_managers(bundle: _ManagerBundle) -> None:
"""
Release resources held by a replaced manager bundle.
CAVEAT vs. the snapshot design: in-flight requests may still hold
this bundle when it is retired. That is harmless while close() is a
no-op (true today — PlaybackManager holds no state of its own,
ChannelManager only caches), but the FIRST manager that implements
a real close() will break those in-flight requests. When that
happens, defer retirement instead (grace period or refcounting)
rather than closing immediately after the swap.
"""
for manager in bundle:
close = getattr(manager, "close", None)
if callable(close):
try:
close()
except Exception as e:
logger.warning(
f"Error closing retired {type(manager).__name__}: {e}"
)
def get_discovery_status(self) -> Dict[str, Any]:
"""Return discovery and endpoint statistics."""
status = self.discovery_service.get_discovery_status()
status["endpoints"] = self.endpoint_manager.get_stats()
return status
def refresh_configuration(self, force: bool = False, warm: bool = True) -> bool:
"""
Re-run configuration discovery and propagate the result everywhere.
Concurrency: guarded by a NON-BLOCKING acquire of _refresh_lock — a
concurrent or re-entrant refresh (e.g. warmup or an auth callback
triggering refresh_configuration again) loses the race and returns
False immediately instead of deadlocking.
Failure-safe ordering:
* Build phase — new EndpointManager and a complete manager bundle
are constructed into locals; a failure here changes nothing.
Caveat: the constructors (and any constructor-time callbacks) run
against the OLD authenticator configuration and the OLD
self.provider_config — the authenticator is reconfigured only
after the build. Harmless unless a refresh changes auth endpoints
or image-scaling config.
* Raising steps first — authenticator reconfiguration and
AuthBridge update. On failure the authenticator is re-pushed the
PREVIOUS config, then the error propagates. Rollback caveat:
_configure_authenticator_from_discovery only sets truthy fields,
so the rollback restores every value the old config defined but
cannot UNSET values that existed only in the new config (e.g. an
openid block present only in the new manifest) — in that narrow
case the authenticator keeps a new-only field alongside otherwise
old state.
* Non-raising commit — plain reference assignments plus ONE atomic
bundle swap. Note: provider_config / endpoint_manager / _managers
are three separate assignments, so a reader of the first two can
momentarily see a different generation than a snapshotted bundle;
accepted today (nothing reads them in that combination
mid-flight). If it ever matters, move the config objects into
_ManagerBundle.
* Retirement — the old bundle's close() hooks fire (no-op today;
see _retire_managers for the in-flight caveat).
Unlike initial discovery, an INCOMPLETE result is rejected: at init
there is nothing to lose, but a refresh must never replace a
known-good configuration with an incomplete one.
The warmup (warm=True) re-runs ChannelManager.get_channels() so the
rebuilt channel manager's station→pid map holds entitled release
pids and its live caches are populated. It is a synchronous,
few-request operation (distribution rights + paginated entitled
feed — ChannelManager ignores its populate_streaming /
prefer_highest_quality parameters on this path). Pass warm=False on
request threads or scheduler ticks and warm explicitly later; a
failed warmup is non-fatal and leaves the provider in the same
state as a fresh one, which PlaybackManager._ensure_live_cache
self-heals on demand.
"""
if not self._refresh_lock.acquire(blocking=False):
logger.warning("Configuration refresh already in progress — skipping")
return False
try:
try:
logger.info("Refreshing provider configuration")
new_config = self.discovery_service.discover_provider_config(force_refresh=force)
if not (new_config and new_config.is_complete):
logger.warning(
"Configuration refresh incomplete — keeping previous configuration"
)
return False
old_config = self.provider_config
old_endpoints = self.endpoint_manager
old_managers = self._managers
# ── Build phase: nothing committed. ─────────────────────────
new_endpoints = EndpointManager(new_config)
bundle = self._construct_domain_managers(new_endpoints, new_config)
# ── Raising steps first. ─────────────────────────────────────
try:
self._configure_authenticator_from_discovery(new_config, new_endpoints)
self._auth.update_provider_config(new_config)
except Exception:
logger.warning(
"Authenticator configuration failed mid-refresh — "
"rolling back to previous configuration"
)
try:
self._configure_authenticator_from_discovery(old_config, old_endpoints)
self._auth.update_provider_config(old_config)
except Exception as rollback_exc:
logger.error(f"Authenticator rollback failed: {rollback_exc}")
raise
# ── Non-raising commit: plain assignments + one atomic swap. ──
self.provider_config = new_config
self.endpoint_manager = new_endpoints
self._managers = bundle
self._retire_managers(old_managers)
# ── Warmup. ───────────────────────────────────────────────────
if warm:
try:
self._managers.channel.get_channels()
except Exception as e:
logger.warning(
f"Post-refresh channel warmup failed (caches stay cold): {e}"
)
logger.info("Configuration refresh successful")
return True
except Exception as e:
logger.error(f"Configuration refresh failed: {e}")
return False
finally:
self._refresh_lock.release()
def register_device(self) -> bool:
"""Perform device registration / authentication."""
try:
logger.info("Performing device registration")
if hasattr(self.authenticator, "perform_device_authentication"):
success = self.authenticator.perform_device_authentication()
if success:
logger.info("✓ Device registration successful")
return True
else:
logger.warning("Device registration failed")
return False
else:
logger.warning("Device authentication not supported in current authenticator")
return False
except Exception as e:
logger.error(f"Device registration failed: {e}")
return False
# ------------------------------------------------------------------ #
# Authentication — thin delegates to AuthBridge #
# ------------------------------------------------------------------ #
def get_persona_token(self, force_refresh: bool = False) -> str:
"""Return a valid persona token (cached). Delegates to AuthBridge."""
return self._auth.get_persona_token(force_refresh=force_refresh)
def _ensure_authenticated(self) -> str:
"""Return a valid persona token (lazy auth). Delegates to AuthBridge."""
return self._auth.ensure_authenticated()
def clear_persona_cache(self) -> None:
"""Discard the in-memory persona token cache. Delegates to AuthBridge."""
self._auth.clear_persona_cache()
def _vod_auth_headers(self) -> Dict[str, str]:
"""Build auth headers for VOD endpoints. Delegates to AuthBridge."""
return self._auth.vod_auth_headers()
def _pvr_auth_headers(self) -> Dict[str, str]:
"""Build auth headers for nPVR endpoints. Delegates to AuthBridge."""
return self._auth.pvr_auth_headers()
# ------------------------------------------------------------------ #
# Image scaling #
# ------------------------------------------------------------------ #
def _build_scaled_image_url(self, original_url: str) -> Optional[str]:
"""Return a scaled logo URL using the image scaling service."""
if not original_url:
return None
if not self.provider_config or not self.provider_config.manifest:
return original_url
image_config = self.provider_config.manifest.image_config
if not image_config.scaling_base_url or not image_config.scaling_call_parameter:
return original_url
call_params: Dict[str, str] = {}
for param in image_config.scaling_call_parameter.split("&"):
if "=" in param:
key, value = param.split("=", 1)
call_params[key] = value
base_url = image_config.scaling_base_url.rstrip("/")
# Only `src` is quoted (it is a raw URL). The manifest-provided call
# parameters are emitted verbatim — they may already be
# percent-encoded, and re-quoting would double-encode them.
params = {**call_params, "x": "120", "y": "42", "ar": "keep", "src": original_url}
query_string = "&".join(
f"{k}={quote(v, safe='') if k == 'src' else v}" for k, v in params.items()
)
return f"{base_url}/iss?{query_string}"
# ------------------------------------------------------------------ #
# Header helpers #
# ------------------------------------------------------------------ #
def _get_dcm_headers(self) -> Dict[str, str]:
return {
"User-Agent": self.user_agent_plain,
"Content-Type": "application/json",
"Accept": "application/json",
"x-dt-session-id": self.session_id,
"x-dt-call-id": self._generate_call_id(),
}
def _get_api_headers(self, require_auth: bool = False) -> Dict[str, str]:
headers: Dict[str, str] = {
"User-Agent": self.user_agent_subscriber if require_auth else self.user_agent_plain,
"Accept": "application/json",
"Content-Type": "application/json",
}
if require_auth:
persona_token = self._ensure_authenticated()
headers["Authorization"] = f"Basic {persona_token}"
return headers
# ------------------------------------------------------------------ #
# Public StreamingProvider API #
# ------------------------------------------------------------------ #
def get_dynamic_manifest_params(
self, channel: StreamingChannel, **kwargs: Any
) -> Optional[str]:
return None
def get_channels(
self,
time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS,
fetch_manifests: bool = False,
populate_streaming_data: bool = True,
prefer_highest_quality: bool = True,
**kwargs: Any,
) -> List[StreamingChannel]:
# NOTE: prefer_highest_quality is accepted for signature stability
# but is a documented no-op — the entitled-channels feed resolves
# SD/HD variants server-side (one station per channel per account).
# ChannelManager spells the flag `populate_streaming`; accept both
# spellings so a caller passing it via **kwargs doesn't hit a
# duplicate-keyword TypeError at the forwarding call below.
populate_streaming = kwargs.pop("populate_streaming", populate_streaming_data)
return self._channel_manager.get_channels(
time_window_hours=time_window_hours,
fetch_manifests=fetch_manifests,
populate_streaming=populate_streaming,
prefer_highest_quality=prefer_highest_quality,
**kwargs,
)
def get_events(
self,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
**kwargs: Any,
) -> List[Event]:
return []
@staticmethod
def _get_playback_id(content_id: str, channel_manager: ChannelManager) -> str:
"""
Convert station_id to playback_id if needed, using the given
ChannelManager so the conversion and the subsequent playback call
operate on the same manager generation.
If content_id is numeric (station_id), convert to playback_id.
Otherwise return as-is (already a playback_id or VOD ID).
"""
if content_id.isdigit():
playback_id = channel_manager.get_playback_id_for_station(content_id)
if playback_id:
logger.debug(f"Converted station_id {content_id} -> playback_id {playback_id}")
return playback_id
logger.warning(f"No playback_id found for station_id {content_id}")
return content_id
return content_id
def get_manifest(
self, content_id: str, content_type: str = CONTENT_TYPE_LIVE, **kwargs: Any
) -> Optional[str]:
# Snapshot one manager generation: the station→pid conversion and
# the playback call must not straddle a refresh swap.
managers = self._managers
# Convert station_id -> playback_id for live channels
if content_type == CONTENT_TYPE_LIVE:
content_id = self._get_playback_id(content_id, managers.channel)
return managers.playback.get_manifest(content_id, content_type, **kwargs)
def get_drm(
self,
content_id: str,
drm_variant: Optional[str] = None,
content_type: str = CONTENT_TYPE_LIVE,
**kwargs: Any,
) -> List[DRMConfig]:
"""
Base signature (StreamingProvider):
get_drm(content_id, drm_variant=None, **kwargs)
drm_variant is accepted to honor the contract but not forwarded:
the verified PlaybackManager signature is
get_drm(content_id, content_type=CONTENT_TYPE_LIVE, **kwargs) —
it has no drm_variant parameter. Unknown or non-string values are
accepted and ignored (nothing depends on them here).
"""
if isinstance(drm_variant, str) and drm_variant.lower() in _MISROUTE_DRM_VARIANT_TOKENS:
# This value is a content-type token, which can only be the
# legacy positional call get_drm(cid, "vod"/"live"/"recording")
# — it would silently route VOD content through the live path
# and return the wrong DRM. No legitimate drm_variant ever
# matches, so this has no false positives.
raise ValueError(
f"{self.provider_name}: get_drm got drm_variant={drm_variant!r}, which is a "
f"content-type token — this looks like the legacy positional call "
f"get_drm(content_id, content_type). Pass content_type by keyword: "
f"get_drm(content_id, content_type={drm_variant!r})"
)
managers = self._managers
# Convert station_id -> playback_id for live channels
if content_type == CONTENT_TYPE_LIVE:
content_id = self._get_playback_id(content_id, managers.channel)
return managers.playback.get_drm(content_id, content_type, **kwargs)
def get_catchup_manifest(
self,
content_id: str,
start_time: int,
end_time: int,
epg_id: Optional[str] = None,
drm_variant: Optional[str] = "auto",
**kwargs: Any,
) -> Optional[str]:
"""
Base signature (ProviderCatchupMixin):
get_catchup_manifest(content_id, start_time, end_time, epg_id=None, **kwargs)
epg_id is accepted to honor the contract but not forwarded: the
verified PlaybackManager signature is
get_catchup_manifest(content_id, start_time, end_time, drm_variant="auto",
**kwargs) — drm_variant is its 4th positional, forwarded as such
below. (PlaybackManager itself ignores start_time/end_time: Magenta2
catchup is a DVR sliding window via dvr_window_length, not a fixed
time-range asset.)
"""
# _KNOWN_DRM_VARIANTS is the vocabulary documented by
# ProviderCatchupMixin, not a guess. Warning only (not an error):
# a misroute here still produces a correct manifest — the value is
# simply ignored — so a loud warning is proportionate.
if epg_id is not None and str(epg_id).lower() in _KNOWN_DRM_VARIANTS:
logger.warning(
f"{self.provider_name}: get_catchup_manifest got epg_id={epg_id!r}, "
f"which looks like a drm_variant — pass drm_variant by keyword"
)
managers = self._managers
content_id = self._get_playback_id(content_id, managers.channel)
return managers.playback.get_catchup_manifest(
content_id, start_time, end_time, drm_variant, **kwargs
)
def get_vod_category(
self,
content_id: str = "",
cursor: Optional[str] = None,
page_size: int = 24,
**kwargs: Any,
) -> Any:
"""Return children of a VOD node (empty string → root)."""
return self._vod_manager.get_children(
content_id=content_id,
cursor=cursor,
page_size=page_size,
**kwargs,
)
def search_vod(
self,
query: str,
cursor: Optional[str] = None,
page_size: int = 24,
**kwargs: Any,
) -> Any:
"""Search the VOD catalogue. Delegates to VodManager.search()."""
return self._vod_manager.search(
query=query,
cursor=cursor,
page_size=page_size,
**kwargs,
)
def get_recordings(self, include_deleted: bool = False, **kwargs: Any) -> Any:
"""Return a list of Recording objects from the nPVR backend."""
recordings = self._recordings_manager.get_recordings(
include_deleted=include_deleted, **kwargs
)
for rec in recordings:
if rec.content_id and rec.manifest_script:
self._recording_url_cache[rec.content_id] = rec.manifest_script
return recordings
def delete_recording(self, recording_id: str, **kwargs: Any) -> None:
self._recordings_manager.delete_recording(recording_id)
def get_recording_manifest(self, recording_id: str, **kwargs: Any) -> Optional[str]:
"""Return the playback URL for a recording by ID (fresh API lookup)."""
return self._recordings_manager.get_recording_manifest(recording_id)
# ------------------------------------------------------------------ #
# Timers (nPVR scheduled recordings — delegates to TimersManager) #
# ------------------------------------------------------------------ #
def get_timer_types(self, **kwargs: Any) -> Any:
"""Return the timer types this provider supports."""
return self._timers_manager.get_timer_types()
def get_timers(self, **kwargs: Any) -> Any:
"""Return the list of currently scheduled timers."""
return self._timers_manager.get_timers(**kwargs)
def add_timer(self, timer: Any, **kwargs: Any) -> Any:
"""Schedule a new timer. Delegates to TimersManager.add_timer()."""
return self._timers_manager.add_timer(timer)
def update_timer(self, timer: Any, **kwargs: Any) -> Any:
"""Update an existing timer. Delegates to TimersManager.update_timer()."""
return self._timers_manager.update_timer(timer)
def delete_timer(
self, client_index: int, force_delete: bool = False, **kwargs: Any
) -> None:
"""Delete/cancel a timer. Delegates to TimersManager.delete_timer()."""
self._timers_manager.delete_timer(client_index, force_delete=force_delete)
def get_epg(
self,
channel_id: str,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
country: Optional[str] = None,
**kwargs: Any,
) -> List[EPGEntry]:
"""
Base signature (ProviderEpgMixin):
get_epg(channel_id, start_time, end_time, country=None, **kwargs)
A Magenta2Provider instance is bound to a single country at
construction (self.country drives discovery/endpoints), so a
differing requested country is logged — not silently ignored — and
the instance's country is served. If Magenta2EpgManager.get_channel_epg
ever gains a country parameter, forward it there instead.
"""
if country and country.lower() != self.country.lower():
logger.warning(
f"{self.provider_name}: EPG requested for country '{country}', "
f"but this instance is bound to '{self.country}'; "
f"serving '{self.country}' data"
)
try:
self._ensure_authenticated()
except Exception as e:
logger.warning(f"{self.provider_name}: Auth failed for EPG: {e}")
return self._epg_manager.get_channel_epg(
channel_id=channel_id,
start_time=start_time,
end_time=end_time,
**kwargs,
)
def get_epg_grid(
self,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
channel_ids: Optional[List[str]] = None,
country: Optional[str] = None,
**kwargs: Any,
) -> Dict[str, List[EPGEntry]]:
"""
Base signature (ProviderEpgMixin):
get_epg_grid(start_time, end_time, channel_ids, country=None, **kwargs)
"""
if country and country.lower() != self.country.lower():
logger.warning(
f"{self.provider_name}: EPG grid requested for country '{country}', "
f"but this instance is bound to '{self.country}'; "
f"serving '{self.country}' data"
)
try:
self._ensure_authenticated()
except Exception as e:
logger.warning(f"{self.provider_name}: Auth failed for EPG grid: {e}")
# Snapshot one manager generation: the channel-list fallback and the
# EPG call must not straddle a refresh swap.
managers = self._managers
# If channel_ids is None, pull the full channel list and use each
# channel's station-ID-derived channel_id (the same ID space the
# EPG manager keys its grid by — see ChannelManager / EPGEntry wiring).
if channel_ids is None:
channels = managers.channel.get_channels(populate_streaming=False)
channel_ids = [ch.channel_id for ch in channels]
return managers.epg.get_epg_grid(
start_time=start_time,
end_time=end_time,
channel_ids=channel_ids,
**kwargs,
)
def get_program_details(self, program_id: str, **kwargs: Any) -> Optional[EPGProgramDetails]:
"""Get detailed metadata for a single programme."""
return self._epg_manager.get_program_details(program_id)
# ------------------------------------------------------------------ #
# Auth state / readiness introspection #
# ------------------------------------------------------------------ #
def _calculate_auth_state(self, context: Any) -> AuthState:
"""Delegates to AuthBridge."""
return self._auth.calculate_auth_state(context)
def _calculate_readiness(self, context: Any) -> Tuple[bool, str]:
"""Delegates to AuthBridge."""
return self._auth.calculate_readiness(context)
def get_auth_details(self, context: Any) -> Dict[str, Any]:
"""Delegates to AuthBridge."""
return self._auth.get_auth_details(self.token_scopes, context)
def debug_authentication(self) -> Dict[str, Any]:
"""
Return auth-state and token-flow diagnostic info.
Secret handling: the provider's own token fields are length +
sha256 fingerprint only. The third-party sections
(token_flow_manager.get_token_status(), get_authentication_capabilities(),
get_sam3_client_status()) run through _redact(), a name-based
recursive redaction that replaces string values under
secret-looking keys. That is a SAFETY NET, not a substitute for
auditing those sources — name-based redaction cannot catch secrets
under innocent keys. Exception reporting uses type names only,
since messages can embed URLs or token fragments.
"""
result: Dict[str, Any] = {
"provider": {
"provider_name": self.provider_name,
"country": self.country,
"platform": self.platform,
}
}
try:
persona_token = self.get_persona_token(force_refresh=False)
persona_info: Dict[str, Any] = {
"available": True,
"length": len(persona_token),
"fingerprint": self._token_fingerprint(persona_token),
}
try:
persona_jwt = PlaybackManager.extract_persona_jwt_from_token(persona_token)
persona_info["jwt_available"] = bool(persona_jwt)
if persona_jwt:
persona_info["jwt_length"] = len(persona_jwt)
persona_info["jwt_fingerprint"] = self._token_fingerprint(persona_jwt)
except Exception as e:
persona_info["jwt_extraction_error"] = type(e).__name__
result["persona_token"] = persona_info
except Exception as e:
result["persona_token"] = {"available": False, "error": type(e).__name__}
tfm = getattr(self.authenticator, "token_flow_manager", None)
if tfm is not None:
result["token_flow_manager"] = {
"available": True,
"token_status": self._redact(tfm.get_token_status()),
}
else:
result["token_flow_manager"] = {
"available": False,
"error": "TokenFlowManager not initialized",
}
if hasattr(self.authenticator, "get_authentication_capabilities"):
result["authentication_capabilities"] = self._redact(
self.authenticator.get_authentication_capabilities()
)
# Snapshot the endpoint manager so all reads see one generation.
endpoint_manager = self.endpoint_manager
result["endpoints"] = {
"has_taa_auth": endpoint_manager.has_endpoint("taa_auth"),
"has_entitlement": endpoint_manager.has_endpoint("entitlement"),
"has_widevine_license": endpoint_manager.has_endpoint("widevine_license"),
"has_mpx_selector": endpoint_manager.has_endpoint("mpx_selector"),
"total_endpoints": len(endpoint_manager.get_all_endpoints()),
}
if hasattr(self.authenticator, "get_sam3_client_status"):
result["sam3_client"] = self._redact(
self.authenticator.get_sam3_client_status()
)
return result
@classmethod
def get_static_supported_countries(cls) -> List[str]:
return list(cls.SUPPORTED_COUNTRIES)