Files
script.service.ultimate/lib/streaming_providers/providers/magentaeu/provider.py
T
2026-02-13 18:55:02 +01:00

575 lines
22 KiB
Python

# streaming_providers/providers/magentaeu/provider.py
# -*- coding: utf-8 -*-
import time
from typing import ClassVar, Dict, List, Optional
from ...base.auth import UserPasswordCredentials
from ...base.models import DRMConfig, DRMSystem, LicenseConfig
from ...base.models.proxy_models import ProxyConfig
from ...base.models.streaming_channel import StreamingChannel
from ...base.network import ProxyConfigManager
from ...base.provider import StreamingProvider
from ...base.utils.logger import logger
from .auth import MagentaAuthenticator
from .constants import (
API_ENDPOINTS,
CONTENT_TYPE_LIVE,
DEFAULT_COUNTRY,
DEFAULT_MAX_RETRIES,
DEFAULT_REQUEST_TIMEOUT,
DRM_SYSTEM_WIDEVINE,
MAGENTA_TV_AT_LOGO,
MAGENTA_TV_PL_LOGO,
MAX_TV_LOGO,
STREAMING_FORMAT_DASH,
SUPPORTED_COUNTRIES,
USER_AGENT,
WV_URL,
get_base_url,
get_bifrost_url,
get_guest_headers,
get_language,
get_natco_key,
)
class MagentaEUProvider(StreamingProvider):
# Provider constants with country-specific logos
PROVIDER_LOGO_AT: ClassVar[str] = MAGENTA_TV_AT_LOGO
PROVIDER_LOGO_PL: ClassVar[str] = MAGENTA_TV_PL_LOGO
PROVIDER_LOGO_HR: ClassVar[str] = MAX_TV_LOGO
PROVIDER_LOGO: ClassVar[str] = ""
SUPPORTED_COUNTRIES = SUPPORTED_COUNTRIES
def __init__(
self,
country: str = DEFAULT_COUNTRY,
config_dir: Optional[str] = None,
proxy_config: Optional[ProxyConfig] = None,
proxy_url: Optional[str] = None,
):
logger.info(f"=== MagentaProvider.__init__ START for country: {country} ===")
if not self.validate_country(country):
supported = ", ".join(self.SUPPORTED_COUNTRIES)
raise ValueError(
f"Unsupported country: {country}. " f"MagentaTV EU supports: {supported}"
)
super().__init__(country=country)
if country not in SUPPORTED_COUNTRIES:
raise ValueError(f"Unsupported country: {country}")
# ✅ AFTER: Using abstraction with automatic proxy resolution (6 lines)
self.http_manager = self._setup_http_manager(
provider_name="magentaeu",
proxy_config=proxy_config,
proxy_url=proxy_url,
config_dir=config_dir,
user_agent=USER_AGENT,
timeout=DEFAULT_REQUEST_TIMEOUT,
max_retries=DEFAULT_MAX_RETRIES,
)
# Initialize attributes
self._device_id = None
self._session_id = None
self.bearer_token = None
self._channels_cache = None
self._channels_cache_timestamp = 0
self._cache_ttl = 3600
# Create authenticator
self.authenticator = MagentaAuthenticator(
country=country,
config_dir=config_dir,
http_manager=self.http_manager,
proxy_config=self.http_manager.config.proxy_config, # Use resolved proxy
)
logger.info(f"=== MagentaProvider.__init__ COMPLETE ===")
def _load_proxy_from_manager(self, config_dir: Optional[str]) -> Optional[ProxyConfig]:
"""Load proxy configuration from ProxyConfigManager"""
try:
proxy_manager = ProxyConfigManager(config_dir)
return proxy_manager.get_proxy_config("magentaeu", self.country)
except Exception as e:
logger.warning(f"Could not load proxy from ProxyConfigManager: {e}")
return None
@property
def provider_name(self) -> str:
return "magentaeu"
@property
def provider_label(self) -> str:
if self.country.lower() == "hr":
return "Max TV (HR)"
else:
return f"Magenta TV ({self.country.upper()})"
# Also update the instance property for consistency
@property
def provider_logo(self) -> str:
"""Instance property that returns country-specific logo."""
return self.get_static_logo(self.country)
@property
def uses_dynamic_manifests(self) -> bool:
return False
@property
def implements_epg(self) -> bool:
return False
@property
def catchup_window(self) -> int:
# This provider offers 168 hours (7 days) of catchup
return 168
@property
def supported_auth_types(self) -> List[str]:
return ["user_credentials"]
def authenticate(self, **kwargs) -> str:
logger.info(f"=== MagentaProvider.authenticate() CALLED with kwargs: {kwargs} ===")
self.bearer_token = self.authenticator.get_bearer_token(
force_refresh=kwargs.get("force_refresh", False)
)
logger.info(f"=== MagentaProvider.authenticate() COMPLETE ===")
return self.bearer_token
def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]:
return None
def refresh_authentication(self) -> str:
"""Force refresh authentication"""
self.bearer_token = self.authenticator.get_bearer_token(force_refresh=True)
return self.bearer_token
def get_channels(self, **kwargs) -> List[StreamingChannel]:
"""Fetch available channels from Magenta TV - no authentication required"""
try:
# USE AUTHENTICATOR'S SESSION IDs (single source of truth)
device_id = (
self.authenticator.current_token.device_id
if self.authenticator.current_token
else ""
)
session_id = (
self.authenticator.current_token.session_id
if self.authenticator.current_token
else ""
)
channels_url = API_ENDPOINTS["EPG_CHANNELS"].format(
bifrost_url=get_bifrost_url(self.country)
)
headers = get_guest_headers(self.country, device_id, session_id)
params = {
"channelMap_id": "",
"includeVirtualChannels": "true",
"natco_key": get_natco_key(self.country),
"app_language": get_language(self.country),
"natco_code": self.country,
}
logger.debug(f"Fetching channels with device_id: {device_id}, session_id: {session_id}")
response = self.http_manager.get(
channels_url,
operation="channels",
headers=headers,
params=params,
timeout=DEFAULT_REQUEST_TIMEOUT,
)
response.raise_for_status()
channels_data = response.json()
channels = self._process_channels_response(channels_data)
self._channels_cache = channels
self._channels_cache_timestamp = time.time()
logger.info(f"Successfully fetched {len(channels)} channels for country {self.country}")
return channels
except Exception as e:
logger.error(f"Error fetching channels from Magenta TV: {e}")
if hasattr(e, "response") and hasattr(e.response, "text"):
logger.error(f"Response content: {e.response.text}")
raise Exception(f"Error fetching channels from Magenta TV: {e}")
def _process_channels_response(self, response_data: Dict) -> List[StreamingChannel]:
"""Process channels response and convert to StreamingChannel objects"""
if "channels" not in response_data:
raise Exception("Invalid channels response structure")
channels = []
for channel_data in response_data["channels"]:
try:
# Extract channel information
title = channel_data.get("title", "Unknown Channel")
logo = channel_data.get("channel_logo", "")
manifest = channel_data.get("video_src_dash", "")
pid = channel_data.get("pid_dash", "")
station_id = channel_data.get("station_id", "")
channel_number = channel_data.get("channel_number", "")
media_pid = channel_data.get("media_pid", "")
is_audio = channel_data.get("is_audio", False)
# Build manifest script
manifest_script_parts = []
if channel_number:
manifest_script_parts.append(f"chno={channel_number}")
if station_id:
manifest_script_parts.append(f"epgid={station_id}")
if media_pid:
manifest_script_parts.append(f"media={media_pid}")
manifest_script = " ".join(manifest_script_parts) if manifest_script_parts else ""
# Create streaming channel
streaming_channel = StreamingChannel(
name=title,
channel_id=station_id or pid or title,
provider=self.provider_name,
logo_url=logo,
mode="live",
session_manifest=False,
manifest=manifest,
manifest_script=manifest_script,
cdm_type=DRM_SYSTEM_WIDEVINE,
use_cdm=True,
cdm=f"pid={pid}" if pid else "",
cdm_mode="external",
video="best",
on_demand=True,
speed_up=True,
content_type=CONTENT_TYPE_LIVE,
country=self.country.upper(),
is_radio=is_audio,
language=get_language(self.country),
streaming_format=STREAMING_FORMAT_DASH,
)
channels.append(streaming_channel)
except Exception as e:
logger.warning(f"Error processing channel data: {e}")
return channels
def enrich_channel_data(
self, channel: StreamingChannel, **kwargs
) -> Optional[StreamingChannel]:
"""
Enrich channel with streaming data
Magenta TV provides manifest URLs directly in channel data,
so this mainly ensures DRM configuration is set up
"""
try:
if not channel.manifest:
return None
# Get DRM config (this requires authentication)
drm_config = self.get_drm_config(channel)
if drm_config:
channel.drm_config = drm_config
return channel
except Exception as e:
logger.warning(f"Error enriching channel data for {channel.name}: {e}")
return None
def _ensure_channels_cache(self, force_refresh: bool = False) -> bool:
"""Ensure channels cache is populated, fetch if empty or forced"""
current_time = time.time()
# Check if cache needs refresh
if (force_refresh or
not self._channels_cache or
(current_time - self._channels_cache_timestamp) > self._cache_ttl):
try:
self._channels_cache = self.get_channels()
self._channels_cache_timestamp = current_time
return True
except Exception as e:
logger.error(f"Failed to refresh channels cache: {e}")
return False
return True
def get_manifest(self, channel_id: str, **kwargs) -> Optional[str]:
"""Get manifest URL for a channel by ID"""
# Ensure cache is populated
if not self._ensure_channels_cache():
return None
# Look for the channel
for channel in self._channels_cache:
if channel.channel_id == channel_id:
return channel.manifest
logger.warning(f"Channel {channel_id} not found in available channels")
return None
def get_catchup_manifest(
self, channel_id: str, start_time: int, end_time: int, **kwargs
) -> Optional[str]:
"""
Get catchup manifest URL for Magenta TV.
Returns the same manifest as get_manifest but extended with
?begin=YYYYMMDDTHHMMSS&end=YYYYMMDDTHHMMSS query parameters.
Args:
channel_id: Channel identifier
start_time: Start time as Unix timestamp (epoch seconds)
end_time: End time as Unix timestamp (epoch seconds)
**kwargs: Additional parameters (epg_id, etc.)
Returns:
Manifest URL with catchup time parameters, or None if channel not found
"""
from ...base.utils.timestamp_converter import TimestampConverter
# Ensure cache is populated first
if not self._ensure_channels_cache():
logger.warning(f"Cannot get catchup manifest for {channel_id}, channels cache unavailable")
return None
# First get the base manifest URL
base_manifest = self.get_manifest(channel_id, **kwargs)
if not base_manifest:
logger.warning(f"Channel {channel_id} not found or has no manifest")
return None
try:
# Convert epoch seconds to ISO basic format (YYYYMMDDTHHMMSS)
start_iso = TimestampConverter.epoch_to_iso(
start_time, format_type="basic", as_utc=True
)
end_iso = TimestampConverter.epoch_to_iso(end_time, format_type="basic", as_utc=True)
# Build the catchup manifest URL
# Check if the manifest already has query parameters
separator = "&" if "?" in base_manifest else "?"
catchup_manifest = f"{base_manifest}{separator}begin={start_iso}&end={end_iso}"
logger.debug(f"Catchup manifest for channel {channel_id}: {catchup_manifest}")
return catchup_manifest
except Exception as e:
logger.error(f"Error building catchup manifest for channel {channel_id}: {e}")
# Fall back to live manifest if catchup formatting fails
logger.warning(f"Falling back to live manifest for channel {channel_id}")
return base_manifest
def get_drm(self, channel_id: str, **kwargs) -> List[DRMConfig]:
"""Get DRM configurations for channel by ID"""
logger.info(f"=== get_drm_configs_by_id CALLED for channel_id: {channel_id} ===")
# Ensure cache is populated
if not self._ensure_channels_cache():
logger.warning(f"Cannot get DRM for {channel_id}, channels cache unavailable")
return []
# Find channel in cache
channel = None
for cached_channel in self._channels_cache:
if cached_channel.channel_id == channel_id:
channel = cached_channel
break
if not channel:
logger.warning(f"Channel with ID {channel_id} not found in cache")
return []
# Get DRM config using the existing method
drm_config = self.get_drm_config(channel, **kwargs)
return [drm_config] if drm_config else []
def get_drm_configs(self, channel: StreamingChannel, **kwargs) -> List[DRMConfig]:
"""Get DRM configurations for channel"""
logger.info(f"=== get_drm_configs CALLED for channel: {channel.name} ===")
drm_config = self.get_drm_config(channel)
return [drm_config] if drm_config else []
def get_drm_config(self, channel: StreamingChannel, **kwargs) -> Optional[DRMConfig]:
"""Get DRM configuration for channel with correct authentication"""
try:
import base64
import json
from urllib.parse import quote # For URL encoding
from .auth import MagentaAuthToken, decode_jwt
from .constants import ACC_URL
pid = channel.cdm.replace("pid=", "") if channel.cdm else ""
logger.info(f"=== get_drm_config: Extracted PID: {pid} ===")
if not pid:
logger.debug(f"No PID found for channel {channel.name}")
return None
# Get access token (authenticate if needed)
if not self.bearer_token:
try:
self.authenticate()
except Exception as e:
logger.warning(f"Authentication failed for DRM config: {e}")
return None
access_token = self.bearer_token
if not access_token:
logger.warning("No bearer token available for DRM config")
return None
# Remove 'Bearer ' prefix if present
if access_token.startswith("Bearer "):
access_token = access_token[7:]
# Decode JWT token to get account details
try:
# Get current token from authenticator
current_token = self.authenticator.current_token
# Use the helper method if token is MagentaAuthToken
if isinstance(current_token, MagentaAuthToken) and hasattr(
current_token, "get_jwt_claims"
):
decoded_payload = current_token.get_jwt_claims()
if not decoded_payload:
logger.warning("Failed to get JWT claims from token")
return None
else:
# Fallback: use decode_jwt helper
decoded_payload = decode_jwt(access_token, verify=False)
except Exception as e:
logger.warning(f"Error decoding JWT token for DRM: {e}")
return None
# Extract account information from JWT payload
account_id = decoded_payload.get("dc_cts_accountId", "")
persona_token = decoded_payload.get("dc_cts_personaToken", "")
if not account_id or not persona_token:
logger.warning("Missing account ID or persona token in JWT payload")
return None
# CORRECT: Build account URI and URL-encode it
account_uri = f"{ACC_URL}/{account_id}"
encoded_account_uri = quote(account_uri, safe="") # URL encode, NOT base64
# Build license URL with parameters
license_url = (
f"{WV_URL}{pid}&" f"token={persona_token}&" f"account={encoded_account_uri}"
)
logger.debug(f"License URL created: {license_url[:100]}...")
# Create DRM config with minimal headers
drm_config = DRMConfig(
system=DRMSystem.WIDEVINE,
priority=1,
license=LicenseConfig.create_with_req_data(
req_data_template="{CHA-RAW}",
server_url=license_url,
req_headers=json.dumps(
{
"User-Agent": USER_AGENT,
"Content-Type": "application/octet-stream",
"Origin": get_base_url(self.country),
"Referer": f"{get_base_url(self.country)}/",
}
),
use_http_get_request=False,
),
)
logger.debug(f"DRM config created successfully for channel {channel.name}")
return drm_config
except Exception as e:
logger.warning(f"Error creating DRM config for {channel.name}: {e}")
return None
def validate_credentials(self, credentials: UserPasswordCredentials) -> bool:
"""Validate Magenta TV credentials"""
try:
# Test authentication with provided credentials
temp_authenticator = MagentaAuthenticator(
# country=credentials.country,
config_dir=(
self.authenticator.settings_manager.config_dir
if hasattr(self.authenticator.settings_manager, "config_dir")
else None
),
http_manager=self.http_manager,
proxy_config=self.http_manager.config.proxy_config,
credentials=credentials,
)
token = temp_authenticator.get_bearer_token()
return bool(token and len(token) > 0)
except Exception as e:
logger.debug(f"Credential validation failed: {e}")
return False
@classmethod
def get_supported_countries(cls) -> List[str]:
"""Get list of supported countries"""
return cls.SUPPORTED_COUNTRIES.copy()
@classmethod
def get_static_logo(cls, country: str = None) -> str:
"""
Override to provide country-specific logos.
Args:
country: Optional country code to get specific logo
Returns:
Logo URL for the specified country, or default if not available
"""
# If country is provided, return country-specific logo
if country:
country_lower = country.lower()
if country_lower == "at":
return cls.PROVIDER_LOGO_AT
elif country_lower == "hr":
return cls.PROVIDER_LOGO_HR
elif country_lower == "pl":
return cls.PROVIDER_LOGO_PL
elif country_lower == "hu":
# Hungary - use AT logo as fallback or add specific one
return cls.PROVIDER_LOGO_AT or cls.PROVIDER_LOGO_HR
elif country_lower == "me":
# Montenegro - use HR logo as fallback or add specific one
return cls.PROVIDER_LOGO_HR
# No country specified - return a sensible default
return cls.PROVIDER_LOGO_HR # or cls.PROVIDER_LOGO_AT
@classmethod
def get_static_label(cls, country: str = None) -> str:
"""Override to provide country-specific labels including Max TV for Croatia"""
if country and country.upper() == "HR":
return "Max TV (HR)"
elif country:
return f"Magenta TV ({country.upper()})"
else:
return "Magenta TV"