Make magenta2 platform configurable

This commit is contained in:
Nirvana
2026-10-21 14:25:27 +02:00
parent da9f8955f9
commit 49a99cb32c
3 changed files with 159 additions and 15 deletions
@@ -0,0 +1,54 @@
# streaming_providers/base/settings/provider_settings_store.py
"""
Small per-provider, per-country settings store (provider_settings.json).
Holds user *choices* (e.g. which client a provider emulates). Deliberately
separate from session.json (volatile state: tokens, device ids — safe to
wipe) and from credentials (secrets).
Layout mirrors session.json: {provider: {country: {key: value}}}
"""
from typing import Any, Optional
from ..utils.logger import logger
from ..utils.vfs import VFS
class ProviderSettingsStore:
FILE = "provider_settings.json"
def __init__(self, config_dir: Optional[str] = None):
self.vfs = VFS()
if config_dir:
self.vfs._base_path = config_dir
self.vfs.mkdirs("")
def get(self, provider: str, country: str, key: str, default: Any = None) -> Any:
data = self.vfs.read_json(self.FILE) or {}
provider_data = data.get(provider)
country_data = provider_data.get(country) if isinstance(provider_data, dict) else None
if not isinstance(country_data, dict):
return default
return country_data.get(key, default)
def set(self, provider: str, country: str, key: str, value: Any) -> bool:
data = self.vfs.read_json(self.FILE) or {}
provider_data = data.get(provider)
if not isinstance(provider_data, dict):
provider_data = data[provider] = {}
country_data = provider_data.get(country)
if not isinstance(country_data, dict):
country_data = provider_data[country] = {}
country_data[key] = value
ok = self.vfs.write_json(self.FILE, data)
if not ok:
logger.error(f"Failed to write {self.FILE}")
return ok
def clear(self, provider: str, country: str, key: str) -> bool:
data = self.vfs.read_json(self.FILE) or {}
country_data = (data.get(provider) or {}).get(country)
if isinstance(country_data, dict) and key in country_data:
del country_data[key]
return self.vfs.write_json(self.FILE, data)
return True
@@ -1276,7 +1276,8 @@ class Magenta2Authenticator(BaseAuthenticator):
Saves:
- tvhubs scope: access_token only
- Provider level: refresh_token and device_id only
- Provider level: refresh_token and device_id, merged into the
existing session (other keys are preserved)
"""
# Save ONLY the access token data under 'tvhubs' scope
scoped_token_data = {
@@ -1291,20 +1292,24 @@ class Magenta2Authenticator(BaseAuthenticator):
self.provider_name, "tvhubs", scoped_token_data, self.country
)
# Save provider session data without access_token and without persona fields
# Only keep refresh_token and device_id
provider_session_data = {
"refresh_token": token.refresh_token,
"device_id": self._device_id,
}
# Merge refresh_token and device_id into the existing provider session.
# save_session() REPLACES the whole [provider][country] blob, so it must
# be handed the loaded session — a fresh dict here would erase the
# 'tvhubs' scope saved just above, plus serial_number, client_platform
# and every other scope.
session_manager = self.settings_manager.session_manager
provider_session_data = (
session_manager.load_session(self.provider_name, self.country) or {}
)
provider_session_data["refresh_token"] = token.refresh_token
provider_session_data["device_id"] = self._device_id
# Save provider session data
self.settings_manager.session_manager.save_session(
session_manager.save_session(
self.provider_name, provider_session_data, self.country
)
logger.info(
"✓ Access token saved under 'tvhubs' scope, only refresh_token and device_id saved at provider level"
"✓ Access token saved under 'tvhubs' scope, refresh_token and device_id merged at provider level"
)
@staticmethod
@@ -15,6 +15,7 @@ that delegates to the domain managers:
Magenta2EpgManager – EPG grid + programme-details (ThePlatform API)
"""
import hashlib
import os
import random
import threading
import uuid
@@ -28,6 +29,7 @@ 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.settings.provider_settings_store import ProviderSettingsStore
from ...base.provider import StreamingProvider
from ...base.utils.logger import logger
from .recordings_manager import RecordingsManager
@@ -112,7 +114,7 @@ class Magenta2Provider(StreamingProvider):
def __init__(
self,
country: str = DEFAULT_COUNTRY,
platform: str = DEFAULT_PLATFORM,
platform: Optional[str] = None,
config_dir: Optional[str] = None,
proxy_config: Optional[ProxyConfig] = None,
proxy_url: Optional[str] = None,
@@ -126,11 +128,13 @@ class Magenta2Provider(StreamingProvider):
f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}"
)
# Platform resolution: explicit arg > MAGENTA2_PLATFORM env var >
# persisted setting (provider_settings.json) > DEFAULT_PLATFORM.
# ProviderRegistry only passes `country`, so in production this is
# what actually selects the emulated client. An unknown value raises
# (no silent fallback) so a typo can't quietly change client identity.
platform = self._resolve_platform(platform, config_dir, country)
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)
@@ -154,6 +158,11 @@ class Magenta2Provider(StreamingProvider):
# the same VFS paths (one device_id / serial_number per installation).
self._session_manager: SessionManager = SessionManager(config_dir)
# Tokens, device_id and serial_number all belong to ONE emulated client
# identity. If the configured platform differs from the one the stored
# session was created for, reset the session BEFORE device_id is read.
self._bind_session_to_platform(platform)
# 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,
@@ -373,6 +382,82 @@ class Magenta2Provider(StreamingProvider):
return f"<redacted len={len(value)} fp={cls._token_fingerprint(value)}>"
return value
# ------------------------------------------------------------------ #
# Platform selection #
# ------------------------------------------------------------------ #
PLATFORM_ENV_VAR: ClassVar[str] = "MAGENTA2_PLATFORM"
PLATFORM_SETTING_KEY: ClassVar[str] = "platform"
@staticmethod
def _validate_platform(platform: str) -> str:
if platform not in MAGENTA2_PLATFORMS:
raise ValueError(
f"Unknown platform '{platform}'. Supported: {list(MAGENTA2_PLATFORMS.keys())}"
)
return platform
@classmethod
def _resolve_platform(
cls, explicit: Optional[str], config_dir: Optional[str], country: str
) -> str:
"""explicit arg > env var > persisted setting > DEFAULT_PLATFORM."""
candidate = explicit or os.environ.get(cls.PLATFORM_ENV_VAR)
if not candidate:
try:
candidate = ProviderSettingsStore(config_dir).get(
"magenta2", country, cls.PLATFORM_SETTING_KEY
)
except Exception as e:
# Unreadable settings file must not take the provider down;
# an invalid *value* still raises below.
logger.warning(f"Could not read Magenta2 platform setting: {e}")
return cls._validate_platform(candidate or DEFAULT_PLATFORM)
@classmethod
def set_platform_setting(
cls, platform: str, country: str = DEFAULT_COUNTRY, config_dir: Optional[str] = None
) -> bool:
"""
Persist the platform choice. Takes effect on the next instantiation,
e.g. registry.reinitialize_provider("magenta2"). Ignored while the
MAGENTA2_PLATFORM env var is set (it takes precedence).
"""
cls._validate_platform(platform)
return ProviderSettingsStore(config_dir).set(
"magenta2", country, cls.PLATFORM_SETTING_KEY, platform
)
def _bind_session_to_platform(self, platform: str) -> None:
"""
Stamp the session with the platform it was created for and reset it
when the platform changes.
Sessions that predate this marker were necessarily created as
DEFAULT_PLATFORM (the registry never passed another one), so a
marker-less, non-empty session is treated as DEFAULT_PLATFORM rather
than wiped on first start after upgrade.
Known cost: the abandoned device_id may still count against
Telekom's device limit, so avoid flipping platforms repeatedly.
"""
session = self._session_manager.load_session(self.provider_name, self.country) or {}
stored = session.get("client_platform")
if stored is None and session:
stored = DEFAULT_PLATFORM
if stored and stored != platform:
logger.warning(
f"Magenta2 platform changed ({stored} -> {platform}): "
f"resetting session (tokens, device_id, serial_number)"
)
self._session_manager.clear_session(self.provider_name, self.country)
session = {}
if session.get("client_platform") != platform:
session["client_platform"] = platform
self._session_manager.save_session(self.provider_name, session, self.country)
def _get_or_create_serial_number(self) -> str:
"""
Return a stable serial number for this installation, persisted