Add Magenta TV (EU)

This commit is contained in:
Nirvana
2025-11-07 15:28:48 +01:00
parent 32b2483397
commit faef6ae781
12 changed files with 659 additions and 308 deletions
+7
View File
@@ -22,13 +22,20 @@ Once configured, your live TV channels from streaming platforms appear directly
## 📺 Supported Providers
Currently supported:
- 🇩🇪 **Joyn (DE)**
- 🇦🇹 **Joyn (AT)**
- 🇨🇭 **Joyn (CH)**
- 🇩🇪 **RTL+**
- 🇦🇹 **Magenta TV (AT)**
- 🇭🇷 **Max TV (HR)**
- 🇵🇱 **Magenta TV (PL)**
- 🇲🇪 **Magenta TV (ME)**
- 🇭🇺 **Magenta TV (HU)**
More providers will be added in future versions.
---
## ✨ Key Features
+23 -13
View File
@@ -380,17 +380,17 @@ class BaseAuthenticator(ABC):
return self.credentials is not None and self.credentials.validate()
# Authentication methods remain mostly unchanged
def authenticate(self, force_refresh: bool = False) -> BaseAuthToken:
"""Authenticate and get access token with persistent storage"""
country_str = f" (country: {self.country})" if self.country else ""
logger.debug(f"[{self.provider_name}{country_str}] Starting authentication, force_refresh={force_refresh}")
# Check current token state for debugging
# DEBUG: Log current token state
if self._current_token:
logger.debug(
f"[{self.provider_name}{country_str}] Current token - is_expired: {self._current_token.is_expired}, "
f"has_refresh: {bool(self._current_token.refresh_token)}")
logger.debug(f"[{self.provider_name}{country_str}] Current token state - "
f"is_expired: {self._current_token.is_expired}, "
f"has_refresh: {bool(self._current_token.refresh_token)}, "
f"needs_refresh: {self._current_token.needs_refresh()}")
else:
logger.debug(f"[{self.provider_name}{country_str}] No current token")
@@ -399,15 +399,27 @@ class BaseAuthenticator(ABC):
logger.info(f"[{self.provider_name}{country_str}] Using existing valid token")
return self._current_token
# 2. Try refresh if available
if (not force_refresh and
# 2. ENHANCED REFRESH LOGIC: Attempt refresh if we have a token with refresh capability
# This covers both: tokens that need refresh AND expired tokens that can be refreshed
should_attempt_refresh = (
not force_refresh and
self._current_token and
self._current_token.refresh_token and
self._current_token.needs_refresh()):
(self._current_token.needs_refresh() or self._current_token.is_expired)
)
logger.debug(f"[{self.provider_name}{country_str}] Refresh decision - "
f"should_attempt_refresh: {should_attempt_refresh}, "
f"force_refresh: {force_refresh}, "
f"has_token: {bool(self._current_token)}, "
f"has_refresh_token: {bool(self._current_token.refresh_token if self._current_token else False)}, "
f"needs_refresh: {self._current_token.needs_refresh() if self._current_token else False}, "
f"is_expired: {self._current_token.is_expired if self._current_token else False}")
if should_attempt_refresh:
logger.info(f"[{self.provider_name}{country_str}] Attempting token refresh")
try:
refreshed_token = self._refresh_token()
refreshed_token = self._refresh_token() # Provider-specific implementation
logger.debug(f"[{self.provider_name}{country_str}] Refresh result: {refreshed_token is not None}")
if refreshed_token:
@@ -416,11 +428,9 @@ class BaseAuthenticator(ABC):
logger.info(f"[{self.provider_name}{country_str}] Token refresh successful")
return self._current_token
else:
logger.debug(
f"[{self.provider_name}{country_str}] Refresh returned None, falling back to full auth")
logger.debug(f"[{self.provider_name}{country_str}] Refresh failed, falling back to full auth")
except Exception as e:
logger.warning(
f"[{self.provider_name}{country_str}] Token refresh failed: {e}, attempting new authentication")
logger.warning(f"[{self.provider_name}{country_str}] Token refresh failed: {e}")
# 3. Ensure we have credentials before attempting full authentication
if not self._ensure_credentials():
@@ -258,16 +258,7 @@ class SessionManager:
return False
def load_token_data(self, provider: str, country: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
Load token data for a provider
Args:
provider: Provider name
country: Optional country code
Returns:
Dictionary with token data or None if not found/expired
"""
"""Load token data for a provider"""
country_str = f" (country: {country})" if country else ""
logger.debug(f"Loading token data for {provider}{country_str}")
@@ -294,10 +285,20 @@ class SessionManager:
f"expires_in={expires_in}, current_time={current_time}, "
f"time_until_expiry={time_until_expiry:.0f}")
if current_time >= (expires_at - 300): # 5 minute buffer
logger.info(f"Token expired for {provider}{country_str} "
f"(expired {abs(time_until_expiry):.0f}s ago)")
return None
is_expired = current_time >= (expires_at - 300) # 5 minute buffer
has_refresh_token = bool(session_data.get('refresh_token'))
logger.debug(f"Token status - is_expired: {is_expired}, has_refresh_token: {has_refresh_token}")
if is_expired:
if has_refresh_token:
# Token is expired BUT we have a refresh token - return the data so refresh can be attempted
logger.info(
f"Token expired for {provider}{country_str} but refresh token available - returning data for refresh")
return session_data
else:
logger.info(f"Token expired for {provider}{country_str} and no refresh token available")
return None
logger.info(f"Loaded valid token data for {provider}{country_str} "
f"(expires in {time_until_expiry:.0f}s, "
+34 -31
View File
@@ -1,7 +1,7 @@
# streaming_providers/base/manager.py
from typing import Dict, List, Optional
from .provider import StreamingProvider
from .models import StreamingChannel
from .models import StreamingChannel, DRMSystem
from .drm import DRMPluginManager
from .utils.logger import logger
@@ -339,48 +339,51 @@ class ProviderManager:
return xmltv_data
def get_channel_drm_configs(self, provider_name: str, channel_id: str, **kwargs) -> List:
"""
Get DRM configurations for a specific channel from a provider.
DRM configs are processed through registered plugins before being returned.
Args:
provider_name: Name of the provider
channel_id: ID of the channel
**kwargs: Additional arguments (e.g., country)
Returns:
List of DRM configuration objects (processed by plugins if available)
Raises:
ValueError: If provider not found
"""
provider = self.get_provider(provider_name)
if not provider:
logger.error(f"ProviderManager: Cannot get DRM configs - provider '{provider_name}' not found")
raise ValueError(f"Provider '{provider_name}' not found")
logger.debug(f"ProviderManager: Getting DRM configs for channel '{channel_id}' from provider '{provider_name}'")
# Get raw DRM configs from provider
drm_configs = provider.get_drm_configs_by_id(channel_id, **kwargs)
logger.debug(f"ProviderManager: Retrieved {len(drm_configs)} raw DRM configs")
# Get manifest URL for the channel
manifest_url = provider.get_manifest(channel_id, **kwargs)
# Extract PSSH data from manifest if available
# Check if we need PSSH data at all
pssh_data_list = []
if manifest_url:
logger.debug(f"ProviderManager: Extracting PSSH data from manifest")
try:
pssh_data_list = self._extract_pssh_from_manifest(manifest_url)
logger.debug(f"ProviderManager: Extracted {len(pssh_data_list)} PSSH data entries")
except Exception as e:
logger.warning(f"ProviderManager: Could not extract PSSH data from manifest: {e}")
if drm_configs and self.drm_plugin_manager.plugins:
# Get DRM systems from configs
config_drm_systems = {config.system for config in drm_configs}
# Get DRM systems that have plugins (excluding GENERIC which processes all)
plugin_drm_systems = set(self.drm_plugin_manager.plugins.keys())
# Check if there's overlap OR if there's a GENERIC plugin (which processes everything)
needs_pssh = bool(
config_drm_systems & plugin_drm_systems or
DRMSystem.GENERIC in plugin_drm_systems
)
if needs_pssh:
manifest_url = provider.get_manifest(channel_id, **kwargs)
if manifest_url:
logger.debug(
f"ProviderManager: PSSH needed - extracting from manifest (matching systems: {config_drm_systems & plugin_drm_systems})")
try:
pssh_data_list = self._extract_pssh_from_manifest(manifest_url)
logger.debug(f"ProviderManager: Extracted {len(pssh_data_list)} PSSH data entries")
except Exception as e:
logger.warning(f"ProviderManager: Could not extract PSSH data from manifest: {e}")
else:
logger.debug(
f"ProviderManager: No matching plugins for DRM systems {config_drm_systems}, skipping PSSH extraction")
else:
logger.debug(f"ProviderManager: No configs or no plugins registered, skipping PSSH extraction")
# Process through DRM plugins with PSSH data
processed_configs = self.drm_plugin_manager.process_drm_configs(drm_configs, pssh_data_list, **kwargs)
logger.info(f"ProviderManager: Processed DRM configs for channel '{channel_id}' - {len(processed_configs)} configs returned")
logger.info(
f"ProviderManager: Processed DRM configs for channel '{channel_id}' - {len(processed_configs)} configs returned")
return processed_configs
+10 -10
View File
@@ -69,18 +69,18 @@ class StreamingProvider(ABC):
"""
pass
def get_drm_configs(self, channel: StreamingChannel, **kwargs) -> List[DRMConfig]:
"""
Get all DRM configurations for a channel
# def get_drm_configs(self, channel: StreamingChannel, **kwargs) -> List[DRMConfig]:
# """
# Get all DRM configurations for a channel
Args:
channel: Channel to get DRM configs for
**kwargs: Additional parameters
# Args:
# channel: Channel to get DRM configs for
# **kwargs: Additional parameters
Returns:
List of DRMConfig objects (can be empty if no DRM is used)
"""
return []
# Returns:
# List of DRMConfig objects (can be empty if no DRM is used)
# """
# return []
def get_drm_configs_by_id(self, channel_id: str, **kwargs) -> List[DRMConfig]:
"""
@@ -8,7 +8,7 @@ import urllib.parse
from datetime import datetime, timedelta
from base64 import b64decode
from json import dumps
from urllib.parse import urlencode
#from urllib.parse import urlencode
from ...base.provider import StreamingProvider
from ...base.models import DRMConfig, LicenseConfig, DRMSystem
@@ -550,50 +550,50 @@ class JoynProvider(StreamingProvider):
logger.error(f"Error getting manifest for {channel.name}: {e}")
return None
def get_drm_configs(self,
channel: StreamingChannel,
needs_base64_wrap: bool = False,
**kwargs) -> List[DRMConfig]:
"""
Get DRM configuration for Joyn channels (Widevine)
# def get_drm_configs(self,
# channel: StreamingChannel,
# needs_base64_wrap: bool = False,
# **kwargs) -> List[DRMConfig]:
# """
# Get DRM configuration for Joyn channels (Widevine)
Args:
channel: StreamingChannel object
needs_base64_wrap: Whether to wrap the license request in base64
**kwargs: Additional parameters
# Args:
# channel: StreamingChannel object
# needs_base64_wrap: Whether to wrap the license request in base64
# **kwargs: Additional parameters
Returns:
List of DRMConfig objects (typically contains one Widevine config)
"""
if not channel.license_url:
return [] # No DRM if no license URL
# Returns:
# List of DRMConfig objects (typically contains one Widevine config)
# """
# if not channel.license_url:
# return [] # No DRM if no license URL
try:
# Get fresh auth token if needed
if not self.authenticator.is_authenticated():
self.bearer_token = self.authenticator.authenticate()
# try:
# # Get fresh auth token if needed
# if not self.authenticator.is_authenticated():
# self.bearer_token = self.authenticator.authenticate()
# Prepare license headers
license_headers = DRM_REQUEST_HEADERS.copy()
license_headers['Authorization'] = f"Bearer {self.bearer_token}"
# # Prepare license headers
# license_headers = DRM_REQUEST_HEADERS.copy()
# license_headers['Authorization'] = f"Bearer {self.bearer_token}"
return [
DRMConfig(
system=DRMSystem.WIDEVINE,
priority=1, # Highest priority for Widevine
license=LicenseConfig(
server_url=channel.license_url,
server_certificate=channel.certificate_url,
req_headers=urlencode(license_headers),
use_http_get_request=False,
wrapper="base64" if needs_base64_wrap else None
)
)
]
# return [
# DRMConfig(
# system=DRMSystem.WIDEVINE,
# priority=1, # Highest priority for Widevine
# license=LicenseConfig(
# server_url=channel.license_url,
# server_certificate=channel.certificate_url,
# req_headers=urlencode(license_headers),
# use_http_get_request=False,
# wrapper="base64" if needs_base64_wrap else None
# )
# )
# ]
except Exception as e:
logger.error(f"Error generating DRM config for {channel.name}: {e}")
return []
# except Exception as e:
# logger.error(f"Error generating DRM config for {channel.name}: {e}")
# return []
def get_manifest(self,
channel_id: str,
@@ -1,6 +1,6 @@
# streaming_providers/providers/magenta_eu/__init__.py
from .provider import MagentaProvider
from .auth import MagentaAuthenticator, MagentaAuthToken, MagentaCredentials
from .auth import MagentaAuthenticator, MagentaAuthToken
from .constants import (
SUPPORTED_COUNTRIES,
DEFAULT_COUNTRY,
@@ -12,7 +12,6 @@ __all__ = [
'MagentaProvider',
'MagentaAuthenticator',
'MagentaAuthToken',
'MagentaCredentials',
'SUPPORTED_COUNTRIES',
'DEFAULT_COUNTRY',
'COUNTRY_CONFIG',
@@ -1,4 +1,4 @@
# streaming_providers/providers/magenta_eu/auth.py
# streaming_providers/providers/magentaeu/auth.py
# -*- coding: utf-8 -*-
import uuid
import json
@@ -6,11 +6,17 @@ import base64
import time
from typing import Dict, Optional, Any
from dataclasses import dataclass, field
from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA
# Updated imports for pycryptodome
try:
# Try pycryptodome first (Kodi script.module.pycryptodome)
from Cryptodome.Cipher import PKCS1_OAEP
from Cryptodome.PublicKey import RSA
except ImportError:
# Fallback to older pycrypto naming
from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA
from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel
from ...base.auth.credentials import UserPasswordCredentials
from ...base.models.proxy_models import ProxyConfig
from ...base.utils.logger import logger
from .constants import (
@@ -36,10 +42,9 @@ from .constants import (
CALL_TYPES,
MANAGE_DEVICE,
BROADCASTING_STREAM_LIMITATION_APPLIES,
get_base_url,
get_bifrost_url,
get_natco_key,
get_app_key,
get_base_url,
get_base_headers,
get_language
)
@@ -49,10 +54,10 @@ class InvalidTokenError(Exception):
pass
def base64url_decode(input: str) -> bytes:
def base64url_decode(input_str: str) -> bytes:
"""Base64 URL decode"""
padding = '=' * (4 - (len(input) % 4))
return base64.urlsafe_b64decode(input + padding)
padding = '=' * (4 - (len(input_str) % 4))
return base64.urlsafe_b64decode(input_str + padding)
def decode_jwt(token: str, verify: bool = True) -> Dict[str, Any]:
@@ -81,21 +86,6 @@ def is_token_valid(token: str) -> bool:
except InvalidTokenError:
return False
@dataclass
class MagentaCredentials(UserPasswordCredentials):
"""Magenta TV credentials with country support"""
country: str = DEFAULT_COUNTRY
def validate(self) -> bool:
"""Validate credentials"""
return bool(self.username and self.password and self.country in SUPPORTED_COUNTRIES)
@property
def credential_type(self) -> str:
return "magenta_user_password"
@dataclass
class MagentaAuthToken(BaseAuthToken):
"""Magenta TV authentication token"""
@@ -150,20 +140,12 @@ class MagentaAuthConfig:
self.x_user_agent = X_USER_AGENT
self.timeout = DEFAULT_REQUEST_TIMEOUT
def get_base_headers(self) -> Dict[str, str]:
"""Get base headers for requests"""
return {
'User-Agent': self.user_agent,
'Accept': 'application/json',
'Content-Type': 'application/json',
}
def get_auth_headers(self, call_type: str = CALL_TYPES['GUEST_USER'],
flow: str = AUTH_FLOWS['START_UP'],
step: str = AUTH_STEPS['GET_ACCESS_TOKEN'],
device_id: str = None, session_id: str = None) -> Dict[str, str]:
"""Get authentication headers"""
headers = self.get_base_headers()
headers = get_base_headers()
headers.update({
'X-User-Agent': self.x_user_agent,
'X-Call-Type': call_type,
@@ -184,7 +166,7 @@ class MagentaAuthConfig:
def encrypt_password(self, password: str) -> str:
"""Encrypt password using RSA public key"""
try:
rsa_key = self.country_config['rsa_key'],
rsa_key = self.country_config['rsa_key']
if not rsa_key:
logger.error(f"No RSA public key configured for country: {self.country}")
return password
@@ -206,10 +188,12 @@ class MagentaAuthenticator(BaseAuthenticator):
credentials=None,
config_dir: Optional[str] = None,
http_manager=None,
proxy_config: Optional[ProxyConfig] = None):
"""
Initialize Magenta authenticator
"""
proxy_config: Optional[ProxyConfig] = None,
device_id: Optional[str] = None, # New parameter
session_id: Optional[str] = None): # New parameter
logger.info(f"=== MagentaAuthenticator.__init__ START ===")
if country not in SUPPORTED_COUNTRIES:
raise ValueError(f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}")
@@ -223,9 +207,10 @@ class MagentaAuthenticator(BaseAuthenticator):
# Setup config
self._config = MagentaAuthConfig(self.country, self._http_manager)
# NOW call parent __init__ - this will setup settings_manager and load session
# Call parent init (this will load existing session if available)
# Call parent init (this will load existing session if available)
super().__init__(
provider_name='magenta_eu',
provider_name='magentaeu',
settings_manager=settings_manager,
credentials=credentials,
country=country,
@@ -233,23 +218,123 @@ class MagentaAuthenticator(BaseAuthenticator):
enable_kodi_integration=True
)
# After parent init, we can extract session data from the loaded token
self._extract_session_data_from_token()
logger.info(f"=== MagentaAuthenticator.__init__ AFTER super().__init__ ===")
def _extract_session_data_from_token(self) -> None:
"""Extract session data from the loaded token (if any)"""
# CORRECT LOGIC: Use stored IDs first, then cookie-based, then random
final_device_id = None
final_session_id = None
# 1. FIRST PRIORITY: Use stored session IDs from loaded token
if self._current_token and isinstance(self._current_token, MagentaAuthToken):
# Session data is already stored in the token
logger.debug("Session data extracted from loaded token")
stored_device_id = self._current_token.device_id
stored_session_id = self._current_token.session_id
if stored_device_id and stored_session_id:
final_device_id = stored_device_id
final_session_id = stored_session_id
logger.debug(f"Using stored session IDs - device_id: {final_device_id}, session_id: {final_session_id}")
# 2. SECOND PRIORITY: Only get cookie-based IDs if no stored IDs available
if not final_device_id or not final_session_id:
# Get cookie-based IDs (second priority)
cookie_device_id, cookie_session_id = device_id, session_id
# If no cookie IDs provided as parameters, initialize guest session
if not cookie_device_id or not cookie_session_id:
cookie_device_id, cookie_session_id = self._initialize_guest_session()
logger.debug(
f"Initialized guest session from cookies - device_id: {cookie_device_id}, session_id: {cookie_session_id}")
if cookie_device_id and cookie_session_id:
final_device_id = cookie_device_id
final_session_id = cookie_session_id
logger.debug(
f"Using cookie-based session IDs - device_id: {final_device_id}, session_id: {final_session_id}")
# 3. LAST RESORT: Generate random IDs (shouldn't happen)
if not final_device_id or not final_session_id:
final_device_id = str(uuid.uuid4())
final_session_id = str(uuid.uuid4())
logger.warning(f"CRITICAL: No session IDs available, using random fallback")
# Ensure current token has the correct IDs
if not self._current_token or not isinstance(self._current_token, MagentaAuthToken):
self._current_token = MagentaAuthToken(
access_token="",
refresh_token="",
token_type="Bearer",
expires_in=0,
issued_at=time.time(),
device_id=final_device_id,
session_id=final_session_id
)
else:
# No token loaded, initialize empty session data
logger.debug("No token loaded, session data will be initialized on first auth")
self._current_token.device_id = final_device_id
self._current_token.session_id = final_session_id
logger.info(f"Final session IDs - device_id: {final_device_id}, session_id: {final_session_id}")
@property
def auth_endpoint(self) -> str:
"""Authentication endpoint - required by BaseAuthenticator"""
return API_ENDPOINTS['LOGIN'].format(natco=self.country)
@property
def current_token(self):
return self._current_token
@property
def channel_map_id(self):
if self._current_token and hasattr(self._current_token, 'channel_map_id'):
return self._current_token.channel_map_id
return ""
def get_auth_headers(self, call_type: str, flow: str, step: str) -> Dict[str, str]:
return self._config.get_auth_headers(call_type, flow, step)
def get_epg_headers(self) -> Dict[str, str]:
return self.get_auth_headers("GUEST_USER", "START_UP", "EPG_CHANNEL")
@property
def http_manager(self):
"""Public access to HTTP manager"""
return self._http_manager
def _initialize_guest_session(self) -> tuple[str, str]:
"""Initialize guest session and get device_id/session_id from cookies"""
try:
startup_url = f"{get_base_url(self.country)}/epg"
headers = get_base_headers()
response = self._http_manager.get(
startup_url,
operation='session_init',
headers=headers,
timeout=DEFAULT_REQUEST_TIMEOUT
)
# Extract cookies from response
device_id = ""
session_id = ""
if hasattr(response, 'cookies'):
cookies = response.cookies.get_dict()
device_id = cookies.get("deviceId", "")
session_id = cookies.get("sessionId", "")
# If no cookies found, generate UUIDs
if not device_id or not session_id:
device_id = str(uuid.uuid4())
session_id = str(uuid.uuid4())
logger.debug(f"Initialized guest session from cookies - device_id: {device_id}, session_id: {session_id}")
return device_id, session_id
except Exception as e:
logger.warning(f"Session initialization failed: {e}")
# Generate fallback IDs
return str(uuid.uuid4()), str(uuid.uuid4())
def _get_auth_headers(self) -> Dict[str, str]:
"""Get headers for authentication request - required by BaseAuthenticator"""
device_id = ""
@@ -270,16 +355,23 @@ class MagentaAuthenticator(BaseAuthenticator):
def _build_auth_payload(self) -> Dict[str, Any]:
"""Build authentication payload - required by BaseAuthenticator"""
if not self.credentials or not isinstance(self.credentials, MagentaCredentials):
raise Exception("No valid Magenta credentials available")
from ...base.auth.credentials import UserPasswordCredentials
if not self.credentials or not isinstance(self.credentials, UserPasswordCredentials):
raise Exception("No valid credentials available")
# Enhanced validation
if not self.credentials.username or not self.credentials.password:
raise Exception("Username and password cannot be empty")
# Get device_id from current token or expect it to be provided via other means
device_id = ""
if self._current_token and isinstance(self._current_token, MagentaAuthToken):
device_id = self._current_token.device_id or ""
# If no device_id, initialize session
# If no device_id, we need to get it from the provider
if not device_id:
device_id, _ = self._initialize_session()
device_id = str(uuid.uuid4())
encrypted_password = self._config.encrypt_password(self.credentials.password)
@@ -304,33 +396,65 @@ class MagentaAuthenticator(BaseAuthenticator):
"broadcastingStreamLimitationApplies": BROADCASTING_STREAM_LIMITATION_APPLIES
},
"telekomLogin": {
"username": self.credentials.username,
"password": encrypted_password
"username": self.credentials.username, # Works for both types!
"password": encrypted_password # Works for both types!
}
}
def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken:
"""Create token from API response - required by BaseAuthenticator"""
# Get existing session data from current token
# PRESERVE the existing session IDs (which follow the correct priority)
device_id = ""
session_id = ""
channel_map_id = ""
if self._current_token and isinstance(self._current_token, MagentaAuthToken):
# Try to get session IDs from multiple sources in priority order:
# 1. First from the response_data itself (when loading from stored session)
if 'device_id' in response_data:
device_id = response_data.get('device_id', '')
if 'session_id' in response_data:
session_id = response_data.get('session_id', '')
# 2. Then from current token (for new authentications)
if (not device_id or not session_id) and self._current_token and isinstance(self._current_token,
MagentaAuthToken):
device_id = self._current_token.device_id or ""
session_id = self._current_token.session_id or ""
channel_map_id = self._current_token.channel_map_id or ""
# If no device_id/session_id, initialize session
if not device_id or not session_id:
device_id, session_id = self._initialize_session()
# 3. If we found session IDs, log it
if device_id and session_id:
logger.debug(f"Creating new token with session IDs - device_id: {device_id}, session_id: {session_id}")
else:
logger.warning(f"No session IDs found in response_data or current_token during token creation")
# DUAL KEY SUPPORT: Handle both camelCase (API responses) and snake_case (stored sessions)
# Access token
access_token = response_data.get('accessToken') or response_data.get('access_token')
if not access_token:
logger.error(f"CRITICAL: No access token found in response data")
logger.error(f"Available keys: {list(response_data.keys())}")
raise Exception("No access token found in response data")
# Refresh token
refresh_token = response_data.get('refreshToken') or response_data.get('refresh_token', '')
# Expires in
expires_in = response_data.get('expiresIn') or response_data.get('expires_in', 3600)
# Token type
token_type = response_data.get('tokenType') or response_data.get('token_type', 'Bearer')
# For stored sessions, issued_at might be in the data, otherwise use current time
issued_at = response_data.get('issued_at', time.time())
token = MagentaAuthToken(
access_token=response_data['accessToken'],
refresh_token=response_data.get('refreshToken', ''),
token_type='Bearer',
expires_in=response_data.get('expiresIn', 3600),
issued_at=time.time(),
access_token=access_token,
refresh_token=refresh_token,
token_type=token_type,
expires_in=expires_in,
issued_at=issued_at,
device_id=device_id,
session_id=session_id,
channel_map_id=channel_map_id
@@ -338,75 +462,61 @@ class MagentaAuthenticator(BaseAuthenticator):
# Classify token
token.auth_level = self._classify_token(token)
logger.debug(f"Token created and classified as: {token.auth_level.value}")
logger.debug(f"Token created successfully from {len(response_data)} data fields")
return token
def get_fallback_credentials(self):
"""Get fallback credentials - required by BaseAuthenticator"""
# Return empty credentials as fallback
return MagentaCredentials(username="", password="", country=self.country)
def _initialize_session(self) -> tuple:
"""Initialize session by visiting startup page, returns (device_id, session_id)"""
try:
startup_url = API_ENDPOINTS['STARTUP_PAGE'].format(
base_url=self._config.country_config['base_url']
)
headers = self._config.get_base_headers()
response = self._http_manager.get(
startup_url,
operation='session_init',
headers=headers,
timeout=self._config.timeout
)
# Extract cookies
device_id = ""
session_id = ""
if hasattr(response, 'cookies'):
cookies = response.cookies.get_dict()
device_id = cookies.get("deviceId", str(uuid.uuid4()))
session_id = cookies.get("sessionId", str(uuid.uuid4()))
logger.debug(f"Session initialized - device_id: {device_id}, session_id: {session_id}")
return device_id, session_id
except Exception as e:
logger.warning(f"Session initialization failed: {e}")
# Generate fallback IDs
return str(uuid.uuid4()), str(uuid.uuid4())
from ...base.auth.credentials import UserPasswordCredentials
return UserPasswordCredentials(username="", password="")
def _perform_authentication(self) -> BaseAuthToken:
"""Perform Magenta TV authentication - required by BaseAuthenticator"""
if not self.credentials or not isinstance(self.credentials, MagentaCredentials):
raise Exception("No valid Magenta credentials available")
# Enhanced credential validation - FIXED VERSION
if not self.credentials:
raise Exception("No credentials available for authentication")
# Accept both MagentaCredentials AND base UserPasswordCredentials
from ...base.auth.credentials import UserPasswordCredentials
if not isinstance(self.credentials, UserPasswordCredentials):
raise Exception(
f"Invalid credential type: {type(self.credentials)}. Expected UserPasswordCredentials or MagentaCredentials")
# Validate credential content
if not self.credentials.username or not self.credentials.password:
raise Exception("Username and password are required for authentication")
logger.info(f"Performing Magenta TV authentication for country: {self.country}")
# Perform login
headers = self._get_auth_headers()
payload = self._build_auth_payload()
try:
# Perform login
headers = self._get_auth_headers()
payload = self._build_auth_payload()
response = self._http_manager.post(
self.auth_endpoint,
operation='auth',
headers=headers,
json_data=payload,
timeout=self._config.timeout
)
logger.debug(f"Authentication payload prepared for user: {self.credentials.username}")
response.raise_for_status()
token_data = response.json()
response = self._http_manager.post(
self.auth_endpoint,
operation='auth',
headers=headers,
json_data=payload,
timeout=self._config.timeout
)
# Handle device limit exceeded
if token_data.get("deviceLimitExceed", False):
logger.info("Device limit exceeded, attempting token upgrade")
token_data = self._upgrade_token(token_data['refreshToken'])
response.raise_for_status()
token_data = response.json()
return self._create_token_from_response(token_data)
# Handle device limit exceeded
if token_data.get("deviceLimitExceed", False):
logger.info("Device limit exceeded, attempting token upgrade")
token_data = self._upgrade_token(token_data['refreshToken'])
return self._create_token_from_response(token_data)
except Exception as e:
logger.error(f"Authentication failed for user {self.credentials.username}: {e}")
raise
def _upgrade_token(self, refresh_token: str) -> Dict[str, Any]:
"""Upgrade token when device limit is exceeded"""
@@ -432,6 +542,16 @@ class MagentaAuthenticator(BaseAuthenticator):
response.raise_for_status()
return response.json()
def _get_session_data(self) -> tuple[str, str, str]:
"""Safely get session data from current token"""
if isinstance(self._current_token, MagentaAuthToken):
return (
self._current_token.device_id or "",
self._current_token.session_id or "",
self._current_token.channel_map_id or ""
)
return "", "", ""
def _refresh_token(self) -> Optional[BaseAuthToken]:
"""Refresh Magenta TV token - override base method"""
if not self._current_token or not self._current_token.refresh_token:
@@ -443,19 +563,34 @@ class MagentaAuthenticator(BaseAuthenticator):
refresh_url = API_ENDPOINTS['REFRESH_TOKEN'].format(natco=self.country)
device_id, session_id, channel_map_id = self._get_session_data()
# Build headers according to your working example
headers = self._config.get_auth_headers(
call_type=CALL_TYPES['AUTH_USER'],
flow=AUTH_FLOWS['START_UP'],
step=AUTH_STEPS['REFRESH_TOKEN']
)
headers['Refresh_token'] = self._current_token.refresh_token
# Add the specific headers from your working example
headers.update({
'Authorization': f'Bearer {self._current_token.access_token}',
'Refresh_token': self._current_token.refresh_token,
'channel': 'Tv',
})
# Build payload matching your working example
payload = {
"clientVersion": self._config.app_version,
"concurrencyLimitParam": DEVICE_CONCURRENCY_PARAM,
"deviceId": self._current_token.device_id or ""
"clientVersion": APP_VERSION, # Use current APP_VERSION
"deviceId": device_id,
"concurrencyLimitParam": DEVICE_CONCURRENCY_PARAM
}
logger.debug(f"Refresh request - URL: {refresh_url}")
logger.debug(
f"Refresh request - Headers: { {k: v for k, v in headers.items() if k not in ['Authorization', 'Refresh_token']} }")
logger.debug(f"Refresh request - Payload: {payload}")
response = self._http_manager.post(
refresh_url,
operation='auth_refresh',
@@ -467,16 +602,16 @@ class MagentaAuthenticator(BaseAuthenticator):
response.raise_for_status()
token_data = response.json()
# Create new token but preserve session data
# Create new token with updated data but preserve session IDs
new_token = MagentaAuthToken(
access_token=token_data['accessToken'],
refresh_token=token_data.get('refreshToken', ''),
refresh_token=token_data.get('refreshToken', self._current_token.refresh_token),
token_type='Bearer',
expires_in=token_data.get('expiresIn', 3600),
issued_at=time.time(),
device_id=self._current_token.device_id,
session_id=self._current_token.session_id,
channel_map_id=self._current_token.channel_map_id
device_id=device_id,
session_id=session_id,
channel_map_id=channel_map_id
)
# Classify token
@@ -486,6 +621,8 @@ class MagentaAuthenticator(BaseAuthenticator):
except Exception as e:
logger.warning(f"Token refresh failed: {e}")
if hasattr(e, 'response') and hasattr(e.response, 'text'):
logger.error(f"Refresh response content: {e.response.text}")
return None
def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel:
@@ -494,7 +631,19 @@ class MagentaAuthenticator(BaseAuthenticator):
if not token or not token.access_token:
return TokenAuthLevel.UNKNOWN
claims = token.get_jwt_claims()
# Only MagentaAuthToken has get_jwt_claims method
if isinstance(token, MagentaAuthToken):
claims = token.get_jwt_claims()
else:
# For BaseAuthToken, try to decode JWT manually
try:
claims = decode_jwt(token.access_token, verify=False)
except InvalidTokenError:
return TokenAuthLevel.UNKNOWN
except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as e:
logger.debug(f"Error decoding JWT token: {e}")
return TokenAuthLevel.UNKNOWN
if not claims:
return TokenAuthLevel.UNKNOWN
@@ -1,8 +1,10 @@
# streaming_providers/providers/magenta_eu/constants.py
# streaming_providers/providers/magentaeu/constants.py
# ============================================================================
# Magenta TV Configuration
# ============================================================================
from typing import Dict
# Supported countries
SUPPORTED_COUNTRIES = ['hr', 'pl', 'me', 'at', 'hu']
@@ -211,4 +213,34 @@ def get_language(country: str) -> str:
def get_rsa_key(country: str) -> str:
"""Get RSA public key for country"""
return get_country_config(country)['rsa_key']
return get_country_config(country)['rsa_key']
def get_base_headers() -> Dict[str, str]:
"""Get base headers for requests"""
return {
'User-Agent': USER_AGENT,
'Accept': 'application/json',
'Content-Type': 'application/json',
}
def get_guest_headers(country: str, device_id: str, session_id: str) -> Dict[str, str]:
"""Get headers for guest/unauthenticated requests"""
import uuid
headers = {
'User-Agent': USER_AGENT,
'X-User-Agent': X_USER_AGENT,
'X-Call-Type': 'GUEST_USER',
'X-Tv-Flow': 'START_UP',
'X-Tv-Step': 'EPG_CHANNEL',
'x-request-session-id': session_id,
'x-request-tracking-id': str(uuid.uuid4()),
'Tenant': 'tv',
'Origin': get_base_url(country),
'App_key': get_app_key(country),
'App_version': APP_VERSION,
'Device-Id': device_id,
'Device-Name': DEVICE_NAME,
}
return headers
@@ -1,17 +1,16 @@
# streaming_providers/providers/magenta_eu/provider.py
# streaming_providers/providers/magentaeu/provider.py
# -*- coding: utf-8 -*-
from typing import Dict, Optional, List
import json
import time
from datetime import datetime, timedelta
from typing import Dict, Optional, List
from ...base.auth import UserPasswordCredentials
from ...base.provider import StreamingProvider
from ...base.models import DRMConfig, LicenseConfig, DRMSystem
from ...base.models.streaming_channel import StreamingChannel
from ...base.network import HTTPManagerFactory, ProxyConfigManager
from ...base.models.proxy_models import ProxyConfig
from ...base.utils.logger import logger
from .auth import MagentaAuthenticator, MagentaCredentials
from .auth import MagentaAuthenticator
from .constants import (
SUPPORTED_COUNTRIES,
DEFAULT_COUNTRY,
@@ -24,10 +23,10 @@ from .constants import (
WV_URL,
CONTENT_TYPE_LIVE,
STREAMING_FORMAT_DASH,
get_base_url,
get_bifrost_url,
get_natco_key,
get_app_key,
get_guest_headers,
get_base_url,
get_language
)
@@ -39,9 +38,8 @@ class MagentaProvider(StreamingProvider):
config_dir: Optional[str] = None,
proxy_config: Optional[ProxyConfig] = None,
proxy_url: Optional[str] = None):
"""
Initialize Magenta provider
"""
logger.info(f"=== MagentaProvider.__init__ START for country: {country} ===")
super().__init__(country=country)
if country not in SUPPORTED_COUNTRIES:
@@ -54,47 +52,46 @@ class MagentaProvider(StreamingProvider):
self._load_proxy_from_manager(config_dir)
)
if self.proxy_config:
logger.info("Using proxy configuration for Magenta TV")
else:
logger.debug("No proxy configuration found for Magenta TV")
# Create HTTP manager
self.http_manager = HTTPManagerFactory.create_for_provider(
provider_name='magenta_eu',
provider_name='magentaeu',
proxy_config=self.proxy_config,
user_agent=USER_AGENT,
timeout=DEFAULT_REQUEST_TIMEOUT,
max_retries=DEFAULT_MAX_RETRIES
)
# Create authenticator
# Initialize ALL instance 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 # Cache TTL in seconds
# Create authenticator - it will handle session initialization internally
self.authenticator = MagentaAuthenticator(
country=country,
config_dir=config_dir,
http_manager=self.http_manager,
proxy_config=self.proxy_config
# No need to pass device_id/session_id - authenticator handles this
)
# Authenticate
try:
self.bearer_token = self.authenticator.get_bearer_token()
except Exception as e:
logger.warning(f"Could not authenticate during initialization: {e}")
self.bearer_token = None
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('magenta_eu', self.country)
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 'magenta_eu'
return 'magentaeu'
@property
def provider_label(self) -> str:
@@ -105,8 +102,9 @@ class MagentaProvider(StreamingProvider):
return False
def authenticate(self, **kwargs) -> str:
"""Authenticate and return bearer token"""
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]:
@@ -118,35 +116,27 @@ class MagentaProvider(StreamingProvider):
return self.bearer_token
def fetch_channels(self, **kwargs) -> List[StreamingChannel]:
"""Fetch available channels from Magenta TV"""
"""Fetch available channels from Magenta TV - no authentication required"""
try:
# Get user account to ensure we have channel map ID
self.authenticator.get_user_account()
# 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)
)
# Get channel map ID from authenticator
channel_map_id = ""
if (self.authenticator._current_token and
isinstance(self.authenticator._current_token,
self.authenticator.__class__.__bases__[0].MagentaAuthToken)):
channel_map_id = self.authenticator._current_token.channel_map_id or ""
headers = get_guest_headers(self.country, device_id, session_id)
params = {
'channelMap_id': channel_map_id,
'channelMap_id': '',
'includeVirtualChannels': 'true',
'natco_key': get_natco_key(self.country),
'app_language': get_language(self.country),
'natco_code': self.country
}
headers = self.authenticator._config.get_auth_headers(
call_type="GUEST_USER",
flow="START_UP",
step="EPG_CHANNEL"
)
logger.debug(f"Fetching channels with device_id: {device_id}, session_id: {session_id}")
response = self.http_manager.get(
channels_url,
@@ -160,10 +150,16 @@ class MagentaProvider(StreamingProvider):
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]:
@@ -234,7 +230,7 @@ class MagentaProvider(StreamingProvider):
if not channel.manifest:
return None
# Get DRM config
# Get DRM config (this requires authentication)
drm_config = self.get_drm_config(channel)
if drm_config:
channel.drm_config = drm_config
@@ -246,53 +242,141 @@ class MagentaProvider(StreamingProvider):
return None
def get_manifest(self, channel_id: str, **kwargs) -> Optional[str]:
"""
Get manifest URL for a specific channel by ID
For Magenta TV, manifests are already provided in channel data
"""
# Since manifests are provided directly in channel data,
# this would need to fetch channel data again or use cached data
if self._channels_cache:
for channel in self._channels_cache:
if channel.channel_id == channel_id:
return channel.manifest
return None
def get_drm_configs_by_id(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} ===")
# Find channel in cache
channel = None
if self._channels_cache:
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"""
"""Get DRM configuration for channel with correct authentication"""
try:
import json
import base64
from .auth import MagentaAuthToken, decode_jwt
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
license_url = f"{WV_URL}{pid}"
headers = DRM_REQUEST_HEADERS.copy()
headers.update({
'Authorization': f'Bearer {self.bearer_token}',
# 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
# Create reencoded session for Basic auth
import base64
reencoded_session = f"{get_base_url(self.country)}/{account_id}:{persona_token}"
basic_auth = base64.b64encode(reencoded_session.encode()).decode()
# Build license headers
headers = {
'Authorization': f'Basic {basic_auth}',
'Content-Type': DRM_REQUEST_HEADERS.get('Content-Type', 'application/octet-stream'),
'Origin': get_base_url(self.country),
'Referer': f"{get_base_url(self.country)}/",
})
'User-Agent': USER_AGENT
}
return DRMConfig(
# Remove any None values from headers
headers = {k: v for k, v in headers.items() if v is not None}
# Create DRM configuration using LicenseConfig
import json
drm_config = DRMConfig(
system=DRMSystem.WIDEVINE,
license_url=license_url,
headers=headers,
challenge_data=b'',
session_id=str(int(time.time()))
priority=1,
license=LicenseConfig(
server_url=license_url,
req_headers=json.dumps(headers),
req_data="{CHA-RAW}",
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: MagentaCredentials) -> bool:
def validate_credentials(self, credentials: UserPasswordCredentials) -> bool:
"""Validate Magenta TV credentials"""
try:
# Test authentication with provided credentials
temp_authenticator = MagentaAuthenticator(
country=credentials.country,
# 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,
@@ -307,6 +391,7 @@ class MagentaProvider(StreamingProvider):
logger.debug(f"Credential validation failed: {e}")
return False
def get_supported_countries(self) -> List[str]:
@staticmethod
def get_supported_countries() -> List[str]:
"""Get list of supported countries"""
return SUPPORTED_COUNTRIES.copy()
@@ -396,11 +396,11 @@ class RTLPlusProvider(StreamingProvider):
print(f"Error parsing DRM configs for RTL+ channel {channel_id}: {e}")
return []
def get_drm_configs(self, channel: StreamingChannel, **kwargs) -> List[DRMConfig]:
"""
Get DRM configurations for a channel
"""
return self.get_drm_configs_by_id(channel.channel_id, **kwargs)
# def get_drm_configs(self, channel: StreamingChannel, **kwargs) -> List[DRMConfig]:
# """
# Get DRM configurations for a channel
# """
# return self.get_drm_configs_by_id(channel.channel_id, **kwargs)
@staticmethod
def get_epg_data(channel_id: str, **kwargs) -> Optional[Dict]:
@@ -415,7 +415,7 @@ class RTLPlusProvider(StreamingProvider):
"""
Get license URL for a DRM-protected channel
"""
drm_configs = self.get_drm_configs(channel, **kwargs)
drm_configs = self.get_drm_configs_by_id(channel.channel_id, **kwargs)
if drm_configs:
# Return the first license URL found
return drm_configs[0].license.server_url
+66 -1
View File
@@ -9,7 +9,7 @@
</category>
<category label="Joyn (DE)">
<setting id="enable_joyn_de" type="bool" label="Enable Joyn (DE)" default="true" />
<setting id="enable_joyn_de" type="bool" label="Enable Joyn (DE)" default="false" />
<setting id="joyn_de_country" type="text" label="Country Code" default="de" visible="false" />
<setting id="joyn_de_username" type="text" label="Username" default="" enable="eq(-2,true)" />
<setting id="joyn_de_password" type="text" label="Password" option="hidden" default="" enable="eq(-3,true)" />
@@ -67,4 +67,69 @@
<setting id="rtlplus_proxy_port" type="number" label="30112" visible="eq(-2,true)" />
</category>
<!-- Magenta TV Austria -->
<category label="Magenta TV (AT)">
<setting id="enable_magentaeu_at" type="bool" label="Enable Magenta TV (AT)" default="false" />
<setting id="magentaeu_at_country" type="text" label="Country Code" default="at" visible="false" />
<setting id="magentaeu_at_username" type="text" label="Username" default="" enable="eq(-2,true)" />
<setting id="magentaeu_at_password" type="text" label="Password" option="hidden" default="" enable="eq(-3,true)" />
<!-- Optional: Proxy settings for AT -->
<setting id="magentaeu_at_proxy_enabled" type="bool" label="Enable Proxy" default="false" enable="eq(-4,true)" />
<setting id="magentaeu_at_proxy_host" type="text" label="Proxy Host" default="" visible="eq(-1,true)" />
<setting id="magentaeu_at_proxy_port" type="number" label="Proxy Port" default="8080" visible="eq(-2,true)" />
</category>
<!-- Magenta TV Croatia -->
<category label="Max TV (HR)">
<setting id="enable_magentaeu_hr" type="bool" label="Enable Max TV (HR)" default="false" />
<setting id="magentaeu_hr_country" type="text" label="Country Code" default="hr" visible="false" />
<setting id="magentaeu_hr_username" type="text" label="Username" default="" enable="eq(-2,true)" />
<setting id="magentaeu_hr_password" type="text" label="Password" option="hidden" default="" enable="eq(-3,true)" />
<!-- Optional: Proxy settings for HR -->
<setting id="magentaeu_hr_proxy_enabled" type="bool" label="Enable Proxy" default="false" enable="eq(-4,true)" />
<setting id="magentaeu_hr_proxy_host" type="text" label="Proxy Host" default="" visible="eq(-1,true)" />
<setting id="magentaeu_hr_proxy_port" type="number" label="Proxy Port" default="8080" visible="eq(-2,true)" />
</category>
<!-- Magenta TV Poland -->
<category label="Magenta TV (PL)">
<setting id="enable_magentaeu_pl" type="bool" label="Enable Magenta TV (PL)" default="false" />
<setting id="magentaeu_pl_country" type="text" label="Country Code" default="pl" visible="false" />
<setting id="magentaeu_pl_username" type="text" label="Username" default="" enable="eq(-2,true)" />
<setting id="magentaeu_pl_password" type="text" label="Password" option="hidden" default="" enable="eq(-3,true)" />
<!-- Optional: Proxy settings for PL -->
<setting id="magentaeu_pl_proxy_enabled" type="bool" label="Enable Proxy" default="false" enable="eq(-4,true)" />
<setting id="magentaeu_pl_proxy_host" type="text" label="Proxy Host" default="" visible="eq(-1,true)" />
<setting id="magentaeu_pl_proxy_port" type="number" label="Proxy Port" default="8080" visible="eq(-2,true)" />
</category>
<!-- Magenta TV Montenegro -->
<category label="Magenta TV (ME)">
<setting id="enable_magentaeu_me" type="bool" label="Enable Magenta TV (ME)" default="false" />
<setting id="magentaeu_me_country" type="text" label="Country Code" default="me" visible="false" />
<setting id="magentaeu_me_username" type="text" label="Username" default="" enable="eq(-2,true)" />
<setting id="magentaeu_me_password" type="text" label="Password" option="hidden" default="" enable="eq(-3,true)" />
<!-- Optional: Proxy settings for ME -->
<setting id="magentaeu_me_proxy_enabled" type="bool" label="Enable Proxy" default="false" enable="eq(-4,true)" />
<setting id="magentaeu_me_proxy_host" type="text" label="Proxy Host" default="" visible="eq(-1,true)" />
<setting id="magentaeu_me_proxy_port" type="number" label="Proxy Port" default="8080" visible="eq(-2,true)" />
</category>
<!-- Magenta TV Hungary -->
<category label="Magenta TV (HU)">
<setting id="enable_magentaeu_hu" type="bool" label="Enable Magenta TV (HU)" default="false" />
<setting id="magentaeu_hu_country" type="text" label="Country Code" default="hu" visible="false" />
<setting id="magentaeu_hu_username" type="text" label="Username" default="" enable="eq(-2,true)" />
<setting id="magentaeu_hu_password" type="text" label="Password" option="hidden" default="" enable="eq(-3,true)" />
<!-- Optional: Proxy settings for HU -->
<setting id="magentaeu_hu_proxy_enabled" type="bool" label="Enable Proxy" default="false" enable="eq(-4,true)" />
<setting id="magentaeu_hu_proxy_host" type="text" label="Proxy Host" default="" visible="eq(-1,true)" />
<setting id="magentaeu_hu_proxy_port" type="number" label="Proxy Port" default="8080" visible="eq(-2,true)" />
</category>
</settings>