mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-21 15:22:28 +02:00
575 lines
22 KiB
Python
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"
|