diff --git a/lib/streaming_providers/base/settings/provider_settings_store.py b/lib/streaming_providers/base/settings/provider_settings_store.py new file mode 100644 index 0000000..8cad7ca --- /dev/null +++ b/lib/streaming_providers/base/settings/provider_settings_store.py @@ -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 \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/auth.py b/lib/streaming_providers/providers/magenta2/auth.py index 9d37524..396fbcf 100644 --- a/lib/streaming_providers/providers/magenta2/auth.py +++ b/lib/streaming_providers/providers/magenta2/auth.py @@ -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 diff --git a/lib/streaming_providers/providers/magenta2/provider.py b/lib/streaming_providers/providers/magenta2/provider.py index c726767..fcd5c1f 100644 --- a/lib/streaming_providers/providers/magenta2/provider.py +++ b/lib/streaming_providers/providers/magenta2/provider.py @@ -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"" 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