From 8a3f3bbdb202684589ed0e2c04df18c9096c8163 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Wed, 25 Mar 2026 11:14:10 +0100 Subject: [PATCH] Add movetv --- .../providers/movetv/auth.py | 304 +++++++++++++ .../providers/movetv/constants.py | 118 +++++ .../providers/movetv/provider.py | 429 ++++++++++++++++++ 3 files changed, 851 insertions(+) create mode 100644 lib/streaming_providers/providers/movetv/auth.py create mode 100644 lib/streaming_providers/providers/movetv/constants.py create mode 100644 lib/streaming_providers/providers/movetv/provider.py diff --git a/lib/streaming_providers/providers/movetv/auth.py b/lib/streaming_providers/providers/movetv/auth.py new file mode 100644 index 0000000..b01eaa7 --- /dev/null +++ b/lib/streaming_providers/providers/movetv/auth.py @@ -0,0 +1,304 @@ +# streaming_providers/providers/movetv/auth.py +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel +from ...base.auth.credentials import UserPasswordCredentials +from ...base.utils.logger import logger +from .constants import MoveTVConfig + + +# --------------------------------------------------------------------------- +# Token +# --------------------------------------------------------------------------- + +@dataclass +class MoveTVAuthToken(BaseAuthToken): + """ + Holds all session state returned by the move.tv login endpoint. + + Fields beyond the BaseAuthToken contract: + - auth_token raw value of the X-Auth-Token header + - customer_id numeric customer identifier + - customer_profile_id active profile id (needed in manifest requests) + - dedicated_server CDN base URL (e.g. https://edge-mts-si-2.mts-si.tv) + - device_id server-assigned device identifier + - widevine_url Widevine license server URL (from drm_server block) + - playready_url PlayReady license server URL (from drm_server block) + """ + + auth_token: str = "" + customer_id: int = 0 + customer_profile_id: int = 0 + dedicated_server: str = "" + device_id: int = 0 + widevine_url: str = "" + playready_url: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "access_token": self.access_token, + "token_type": self.token_type, + "expires_in": self.expires_in, + "issued_at": self.issued_at, + "refresh_token": self.refresh_token, + "refresh_expires_in": self.refresh_expires_in, + "auth_level": self.auth_level.value, + # move.tv specifics + "auth_token": self.auth_token, + "customer_id": self.customer_id, + "customer_profile_id": self.customer_profile_id, + "dedicated_server": self.dedicated_server, + "device_id": self.device_id, + "widevine_url": self.widevine_url, + "playready_url": self.playready_url, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "MoveTVAuthToken": + return cls( + access_token=data.get("access_token", data.get("auth_token", "")), + token_type=data.get("token_type", "token"), + expires_in=data.get("expires_in", 86400), + issued_at=data.get("issued_at", time.time()), + refresh_token=data.get("refresh_token"), + refresh_expires_in=data.get("refresh_expires_in", 0), + auth_level=TokenAuthLevel( + data.get("auth_level", TokenAuthLevel.USER_AUTHENTICATED.value) + ), + auth_token=data.get("auth_token", ""), + customer_id=data.get("customer_id", 0), + customer_profile_id=data.get("customer_profile_id", 0), + dedicated_server=data.get("dedicated_server", ""), + device_id=data.get("device_id", 0), + widevine_url=data.get("widevine_url", ""), + playready_url=data.get("playready_url", ""), + ) + + +# --------------------------------------------------------------------------- +# Authenticator +# --------------------------------------------------------------------------- + +class MoveTVAuthenticator(BaseAuthenticator): + """ + Authenticator for the move.tv / MTS-SI platform. + + Authentication flow + ------------------- + 1. POST /api/v2/login with username/password + fixed device constants. + 2. On success the response carries: + - auth_token → used as X-Auth-Token on every subsequent request + - refresh_token → used to renew the session without re-entering credentials + - dedicated_server → CDN base URL; manifest URLs are derived from it + - customer_id / device_id / profile.id → required for manifest source requests + 3. All of the above is stored on MoveTVAuthToken and persisted via the + base-class settings_manager. + """ + + def __init__( + self, + proxy_config=None, + http_manager=None, + settings_manager=None, + credentials: Optional[UserPasswordCredentials] = None, + country: Optional[str] = None, + config_dir: Optional[str] = None, + enable_kodi_integration: bool = True, + ): + super().__init__( + provider_name="movetv", + settings_manager=settings_manager, + credentials=credentials, + country=country, + config_dir=config_dir, + enable_kodi_integration=enable_kodi_integration, + ) + self._proxy_config = proxy_config + self._http_manager = http_manager + + # ------------------------------------------------------------------ + # BaseAuthenticator abstract contract + # ------------------------------------------------------------------ + + @property + def auth_endpoint(self) -> str: + return MoveTVConfig.login_url() + + def _get_auth_headers(self) -> dict: + """Headers for the login request (X-Auth-Token: null sentinel).""" + return MoveTVConfig.get_base_headers(auth_token=None) + + def _build_auth_payload(self) -> dict: + """ + Build the login POST body. + + Called by the base class only when credentials are already validated, + so self.credentials is safe to access here. + """ + uid = self.get_device_id() + return { + "username": self.credentials.username, + "password": self.credentials.password, + "partnerId": MoveTVConfig.PARTNER_ID, + "deviceName": MoveTVConfig.DEVICE_NAME, + "deviceModelId": MoveTVConfig.DEVICE_MODEL_ID, + "uid": uid, + "appVersion": MoveTVConfig.APP_VERSION, + } + + def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel: + """move.tv only issues user-authenticated tokens.""" + return TokenAuthLevel.USER_AUTHENTICATED + + def get_fallback_credentials(self): + """move.tv has no anonymous / client-credentials fallback.""" + return None + + def has_user_credentials(self) -> bool: + return ( + isinstance(self.credentials, UserPasswordCredentials) + and self.credentials.validate() + ) + + def get_current_token_level(self) -> TokenAuthLevel: + if self._current_token: + return self._current_token.auth_level + return TokenAuthLevel.UNKNOWN + + # ------------------------------------------------------------------ + # Token persistence helpers + # ------------------------------------------------------------------ + + def _create_token_from_response(self, data: Dict[str, Any]) -> MoveTVAuthToken: + """Reconstruct a MoveTVAuthToken from persisted dict data.""" + return MoveTVAuthToken.from_dict(data) + + # ------------------------------------------------------------------ + # Authentication + # ------------------------------------------------------------------ + + def _perform_authentication(self) -> MoveTVAuthToken: + """ + Full username / password login against /api/v2/login. + Delegates payload and header construction to the abstract helpers so + the base class can call them consistently. + """ + if not self.credentials or not self.credentials.validate(): + raise ValueError("move.tv: No valid credentials available for authentication") + + logger.debug(f"move.tv: POST {self.auth_endpoint}") + + response = self._http_manager.post( + self.auth_endpoint, + operation="auth", + json=self._build_auth_payload(), + headers=self._get_auth_headers(), + ) + response.raise_for_status() + data = response.json() + + if not data.get("success"): + raise ValueError(f"move.tv: Login failed – API returned success=false: {data}") + + return self._parse_login_response(data) + + def _refresh_token(self) -> Optional[MoveTVAuthToken]: + """ + The MTS-SI API does not expose a dedicated token-refresh endpoint in + the captured traffic. We fall back to a full re-authentication using + the stored credentials. If credentials are unavailable (e.g. the + refresh_token value can be used in a future update), None is returned + and the base class will trigger _perform_authentication(). + """ + if not self.has_user_credentials(): + logger.debug("move.tv: No credentials available for token refresh") + return None + + logger.info("move.tv: Refreshing session via full re-authentication") + try: + return self._perform_authentication() + except Exception as e: + logger.warning(f"move.tv: Re-authentication during refresh failed: {e}") + return None + + # ------------------------------------------------------------------ + # Response parsing + # ------------------------------------------------------------------ + + @staticmethod + def _parse_login_response(data: Dict[str, Any]) -> MoveTVAuthToken: + """ + Map the raw /api/v2/login JSON response onto a MoveTVAuthToken. + + The API does not return a numeric expires_in; the auth_token contains + an 'expires-' component in the protection header of manifest + responses (typically ~24 h). We default to 86 400 s (24 h) so the + base-class expiry logic behaves sensibly. + """ + auth_token: str = data.get("auth_token", "") + refresh_token: str = data.get("refresh_token", "") + customer_id: int = data.get("customer_id", 0) + device_id: int = data.get("device_id", 0) + dedicated_server: str = data.get("dedicated_server", "") + + profile: Dict = data.get("profile", {}) + customer_profile_id: int = profile.get("id", 0) + + drm_server: Dict = data.get("drm_server", {}) + widevine_url: str = drm_server.get("widevine", "") + playready_url: str = drm_server.get("playready", "") + + logger.info( + f"move.tv: Login successful – customer_id={customer_id}, " + f"device_id={device_id}, dedicated_server={dedicated_server}" + ) + + return MoveTVAuthToken( + # BaseAuthToken fields + access_token=auth_token, # used as bearer / X-Auth-Token + token_type="token", + expires_in=86400, # 24 h default; no numeric TTL in response + issued_at=time.time(), + refresh_token=refresh_token if refresh_token else None, + refresh_expires_in=0, + auth_level=TokenAuthLevel.USER_AUTHENTICATED, + credential_type="user_password", + # move.tv specifics + auth_token=auth_token, + customer_id=customer_id, + customer_profile_id=customer_profile_id, + dedicated_server=dedicated_server, + device_id=device_id, + widevine_url=widevine_url, + playready_url=playready_url, + ) + + # ------------------------------------------------------------------ + # Accessors used by the provider + # ------------------------------------------------------------------ + + def get_auth_token(self, force_refresh: bool = False) -> str: + """ + Return the raw X-Auth-Token string (not a Bearer prefix). + Authenticates if no valid token is held. + """ + token = self.authenticate(force_refresh=force_refresh) + return token.auth_token # type: ignore[attr-defined] + + def get_session_info(self) -> Optional[Dict[str, Any]]: + """ + Return the session identifiers needed for manifest source requests. + Returns None when not authenticated. + """ + if not self._current_token or self._current_token.is_expired: + return None + t: MoveTVAuthToken = self._current_token # type: ignore[assignment] + return { + "customer_id": t.customer_id, + "customer_profile_id": t.customer_profile_id, + "device_id": t.device_id, + "dedicated_server": t.dedicated_server, + "auth_token": t.auth_token, + } \ No newline at end of file diff --git a/lib/streaming_providers/providers/movetv/constants.py b/lib/streaming_providers/providers/movetv/constants.py new file mode 100644 index 0000000..79846bb --- /dev/null +++ b/lib/streaming_providers/providers/movetv/constants.py @@ -0,0 +1,118 @@ +# streaming_providers/providers/movetv/constants.py +from typing import Dict, Optional + + +class MoveTVConfig: + """ + Central configuration for the move.tv / MTS-SI provider. + + All endpoint paths and fixed request parameters live here so that + provider.py and auth.py never contain raw strings or magic numbers. + """ + + # ------------------------------------------------------------------------- + # Base URLs + # ------------------------------------------------------------------------- + API_BASE_URL: str = "https://api2.mts-si.tv" + WEB_ORIGIN: str = "https://play.move.tv" + WEB_REFERER: str = "https://play.move.tv/" + + # ------------------------------------------------------------------------- + # API endpoint paths (relative to API_BASE_URL) + # ------------------------------------------------------------------------- + PATH_LOGIN: str = "/api/v2/login" + PATH_LIVE_CHANNELS: str = "/api/v2/content/live/all" + PATH_LIVE_SOURCE: str = "/api/v2/content/live/source/get" + + # ------------------------------------------------------------------------- + # Device / app constants sent in every login payload + # ------------------------------------------------------------------------- + PARTNER_ID: int = 2 + DEVICE_MODEL_ID: int = 10 + DEVICE_NAME: str = "Chrome 146" + APP_VERSION: str = "3.4.8" + + # ------------------------------------------------------------------------- + # HTTP / request defaults + # ------------------------------------------------------------------------- + USER_AGENT: str = ( + "Mozilla/5.0 (X11; Linux x86_64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/146.0.0.0 Safari/537.36" + ) + TIMEOUT: int = 30 + + # ------------------------------------------------------------------------- + # Token sentinel: the API uses the literal string "null" for unauthenticated + # requests instead of omitting the header entirely. + # ------------------------------------------------------------------------- + UNAUTHENTICATED_TOKEN: str = "null" + + # ------------------------------------------------------------------------- + # Image / logo base URL (prepend to relative picture.icon paths) + # ------------------------------------------------------------------------- + IMAGE_BASE_URL: str = "https://api2.mts-si.tv" + + # ------------------------------------------------------------------------- + # Streaming type identifier used in the manifest source request + # ------------------------------------------------------------------------- + DTYPE_DASH: int = 1 + + # ------------------------------------------------------------------------- + # Header builders + # ------------------------------------------------------------------------- + + @classmethod + def get_base_headers(cls, auth_token: Optional[str] = None) -> Dict[str, str]: + """ + Returns headers shared by every request. + + When *auth_token* is None the unauthenticated sentinel value is used + (as observed in the login request capture). + """ + return { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Content-Type": "application/json", + "Origin": cls.WEB_ORIGIN, + "Referer": cls.WEB_REFERER, + "User-Agent": cls.USER_AGENT, + "X-Auth-Token": auth_token if auth_token else cls.UNAUTHENTICATED_TOKEN, + } + + @classmethod + def get_api_headers(cls, auth_token: Optional[str] = None) -> Dict[str, str]: + """Headers for authenticated JSON API calls (channels, manifest source).""" + return cls.get_base_headers(auth_token) + + # ------------------------------------------------------------------------- + # Full endpoint URL helpers + # ------------------------------------------------------------------------- + + @classmethod + def login_url(cls) -> str: + return f"{cls.API_BASE_URL}{cls.PATH_LOGIN}" + + @classmethod + def channels_url(cls) -> str: + return f"{cls.API_BASE_URL}{cls.PATH_LIVE_CHANNELS}" + + @classmethod + def live_source_url(cls) -> str: + return f"{cls.API_BASE_URL}{cls.PATH_LIVE_SOURCE}" + + # ------------------------------------------------------------------------- + # Logo URL helper + # ------------------------------------------------------------------------- + + @classmethod + def build_logo_url(cls, icon_path: Optional[str]) -> Optional[str]: + """ + Turns a relative icon path like '/images/logo/rts1_dark.png' into an + absolute URL. Returns None when *icon_path* is falsy. + """ + if not icon_path: + return None + if icon_path.startswith("http"): + return icon_path + return f"{cls.IMAGE_BASE_URL}{icon_path}" \ No newline at end of file diff --git a/lib/streaming_providers/providers/movetv/provider.py b/lib/streaming_providers/providers/movetv/provider.py new file mode 100644 index 0000000..1e32091 --- /dev/null +++ b/lib/streaming_providers/providers/movetv/provider.py @@ -0,0 +1,429 @@ +# streaming_providers/providers/movetv/provider.py +import requests +from typing import ClassVar, Dict, List, Optional, Any + +from ...base.models import DRMConfig, StreamingChannel +from ...base.models.proxy_models import ProxyConfig +from ...base.provider import StreamingProvider +from ...base.utils.logger import logger +from .auth import MoveTVAuthenticator +from .constants import MoveTVConfig + + +class MoveTVChannel(StreamingChannel): + """ + Extends StreamingChannel with move.tv-specific fields. + + content_id stores the *liveId* so that get_manifest(content_id) can post + it directly to the source endpoint without any mapping lookup. + + Extra fields + ------------ + catalog_id : int – the original contentId from the channel list + (retained for EPG / catch-up use) + catchup_hours : int – how many hours of catch-up are available (0 = none) + stream_uid : str – the streamUid used by the CDN (e.g. "rts1") + play_auth_header: str – the X-Play-Auth value delivered with the manifest + source response; callers must inject this when + requesting the actual .mpd / .m3u8 from the CDN + """ + + def __init__(self, *args, catalog_id: int = 0, catchup_hours: int = 0, + stream_uid: str = "", play_auth_header: str = "", **kwargs): + super().__init__(*args, **kwargs) + self.catalog_id: int = catalog_id + self.catchup_hours: int = catchup_hours + self.stream_uid: str = stream_uid + self.play_auth_header: str = play_auth_header + + def to_dict(self) -> Dict: + result = super().to_dict() + result["CatalogId"] = self.catalog_id + result["CatchupHours"] = self.catchup_hours + result["StreamUid"] = self.stream_uid + result["PlayAuthHeader"] = self.play_auth_header + return result + + +class MoveTVProvider(StreamingProvider): + """ + Streaming provider for move.tv (MTS-SI platform). + + Authentication + -------------- + Token-based: login returns an auth_token used as X-Auth-Token on all + subsequent requests. No anonymous / client-credentials fallback exists. + + Channel list + ------------ + GET /api/v2/content/live/all – only subscribed channels are kept. + catchup_hours comes from catchup.duration (already in hours). + + content_id convention + --------------------- + liveId is stored as content_id; the original contentId is kept in + catalog_id. This means get_manifest(content_id) can post liveId directly + with no mapping lookup. + + Manifest + -------- + POST /api/v2/content/live/source/get – requires customer_id, + customer_profile_id, liveId (= content_id) and dtype. Returns a + content_url (.mpd) and an X-Play-Auth protection header. This provider + returns the content_url only; the caller injects X-Play-Auth. + + DRM + --- + The AES-128 / token-based protection is handled via X-Play-Auth (see + above). Widevine/PlayReady are not active in observed traffic; get_drm() + returns an empty list. + """ + + PROVIDER_LABEL: ClassVar[str] = "move.tv" + SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["user_credentials"] + + def __init__( + self, + country: str = "SI", + config: Optional[Dict] = None, + proxy_config: Optional[ProxyConfig] = None, + ): + super().__init__(country) + + # HTTP manager (shared with authenticator) + self.http_manager = self._setup_http_manager( + provider_name="movetv", + proxy_config=proxy_config, + user_agent=MoveTVConfig.USER_AGENT, + timeout=MoveTVConfig.TIMEOUT, + ) + + # Authenticator + self.authenticator = MoveTVAuthenticator( + proxy_config=proxy_config, + http_manager=self.http_manager, + ) + + # Share the same http_manager session with the authenticator + self.http_manager = self._share_http_manager_with_authenticator(self.authenticator) + + # Attempt authentication at startup; non-fatal if it fails + try: + self.authenticator.authenticate() + logger.info("move.tv: Authentication successful during initialisation") + except Exception as exc: + logger.warning(f"move.tv: Could not authenticate during initialisation: {exc}") + + # ------------------------------------------------------------------ + # StreamingProvider identity properties + # ------------------------------------------------------------------ + + @property + def provider_name(self) -> str: + return "movetv" + + @property + def provider_label(self) -> str: + return self.PROVIDER_LABEL + + @property + def provider_logo(self) -> str: + return "" # No hosted logo URL known at this time + + @property + def uses_dynamic_manifests(self) -> bool: + # Manifests are fetched per-play via the source endpoint + return True + + @property + def implements_epg(self) -> bool: + return False + + @property + def supported_auth_types(self) -> List[str]: + return self.SUPPORTED_AUTH_TYPES + + # ------------------------------------------------------------------ + # Header helpers + # ------------------------------------------------------------------ + + def _authenticated_headers(self) -> Dict[str, str]: + """Return API headers with the current X-Auth-Token injected.""" + auth_token = self.authenticator.get_auth_token() + return MoveTVConfig.get_api_headers(auth_token=auth_token) + + # ------------------------------------------------------------------ + # get_channels + # ------------------------------------------------------------------ + + def get_channels(self, **kwargs) -> List[MoveTVChannel]: + """ + Fetch the live channel list and return only subscribed channels. + + Each returned MoveTVChannel has: + - name, content_id (= str(contentId)), live_id, stream_uid + - logo_url + - catchup_hours (from catchup.duration) + - session_manifest=True so callers know to call get_manifest() + """ + try: + headers = self._authenticated_headers() + response = self.http_manager.get( + MoveTVConfig.channels_url(), + operation="api", + headers=headers, + ) + response.raise_for_status() + data = response.json() + + if not data.get("success"): + logger.error("move.tv: channels endpoint returned success=false") + return [] + + channels: List[MoveTVChannel] = [] + for item in data.get("content", []): + channel = self._parse_channel_item(item) + if channel: + channels.append(channel) + + self.channels = channels # type: ignore[assignment] + logger.info(f"move.tv: Loaded {len(channels)} subscribed channels") + return channels + + except requests.RequestException as exc: + logger.error(f"move.tv: HTTP error fetching channels: {exc}") + # One retry after token invalidation + try: + logger.info("move.tv: Retrying channel fetch after token refresh …") + self.authenticator.invalidate_token() + headers = self._authenticated_headers() + response = self.http_manager.get( + MoveTVConfig.channels_url(), + operation="api", + headers=headers, + ) + response.raise_for_status() + data = response.json() + + channels = [] + for item in data.get("content", []): + channel = self._parse_channel_item(item) + if channel: + channels.append(channel) + + self.channels = channels # type: ignore[assignment] + return channels + except Exception as retry_exc: + logger.error(f"move.tv: Channel fetch retry failed: {retry_exc}") + return [] + except Exception as exc: + logger.error(f"move.tv: Unexpected error fetching channels: {exc}") + return [] + + def _parse_channel_item(self, item: Dict[str, Any]) -> Optional[MoveTVChannel]: + """ + Parse a single item from the /api/v2/content/live/all content array. + + Returns None for: + - unsubscribed channels + - items missing required identifiers + """ + try: + # Drop unsubscribed channels + if not item.get("subscribed", False): + return None + + catalog_id = item.get("contentId") + live_id = item.get("liveId") + name = item.get("contentName", "") + stream_uid = item.get("streamUid", "") + + # Both identifiers are required + if not live_id or not catalog_id or not name: + logger.debug(f"move.tv: Skipping channel item with missing ids/name: {item}") + return None + + # Logo + picture = item.get("picture", {}) + logo_url = MoveTVConfig.build_logo_url(picture.get("icon")) + + # Catch-up duration — the API field is already in hours + catchup: Dict = item.get("catchup", {}) + catchup_hours: int = int(catchup.get("duration", 0)) if catchup else 0 + + # Audio-only channels + is_audio = bool(item.get("audioOnly", False)) + + # liveId stored as content_id so get_manifest() needs no mapping + channel = MoveTVChannel( + name=name, + content_id=str(live_id), + provider=self.provider_name, + logo_url=logo_url, + mode="live", + session_manifest=True, + manifest=None, + content_type="RADIO" if is_audio else "LIVE", + quality="AUDIO" if is_audio else None, + is_radio=is_audio, + language="sr", + country=self.country, + # move.tv specifics + catalog_id=int(catalog_id), + catchup_hours=catchup_hours, + stream_uid=stream_uid, + ) + + channel.channel_number = item.get("contentPosition") + return channel + + except Exception as exc: + logger.warning(f"move.tv: Error parsing channel item: {exc} — {item}") + return None + + # ------------------------------------------------------------------ + # get_manifest + # ------------------------------------------------------------------ + + def get_manifest(self, content_id: str, **kwargs) -> Optional[str]: + """ + Fetch the streaming manifest URL for a channel. + + The caller is responsible for injecting the X-Play-Auth header when + requesting the returned .mpd / .m3u8 URL from the CDN. The header + value can be retrieved via get_play_auth_header(). + + ``content_id`` is the liveId stored on MoveTVChannel.content_id. + It is posted directly to the source endpoint with no mapping required. + """ + try: + # content_id IS the liveId — cast directly, no mapping needed + try: + live_id = int(content_id) + except (ValueError, TypeError): + logger.error(f"move.tv: content_id is not a valid liveId: {content_id!r}") + return None + + source_data = self._fetch_live_source(live_id) + if source_data is None: + return None + + content_url: Optional[str] = source_data.get("content_url") + if not content_url: + logger.warning( + f"move.tv: No content_url in source response for liveId={live_id}" + ) + return None + + # Cache X-Play-Auth on the channel for cheap retrieval by callers + self._store_play_auth(content_id, source_data) + + logger.info(f"move.tv: Manifest URL for liveId={live_id}: {content_url}") + return content_url + + except requests.RequestException as exc: + logger.error(f"move.tv: HTTP error fetching manifest for {content_id}: {exc}") + return None + except Exception as exc: + logger.error(f"move.tv: Unexpected error fetching manifest for {content_id}: {exc}") + return None + + def get_play_auth_header(self, content_id: str) -> Optional[str]: + """ + Return the X-Play-Auth header value for a channel. + + Populated automatically during get_manifest(). If not yet cached, + get_manifest() is called implicitly. + """ + channel = self._channel_by_id(content_id) + if channel and channel.play_auth_header: + return channel.play_auth_header + + # Trigger manifest fetch to populate the header + self.get_manifest(content_id) + channel = self._channel_by_id(content_id) + return channel.play_auth_header if channel else None + + # ------------------------------------------------------------------ + # get_drm (stub – AES-128/token auth, no active Widevine/PlayReady) + # ------------------------------------------------------------------ + + def get_drm(self, content_id: str, **kwargs) -> List[DRMConfig]: + """ + move.tv uses AES-128 token-based stream protection via X-Play-Auth. + No Widevine or PlayReady DRM is active in observed traffic. + """ + return [] + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _fetch_live_source(self, live_id: int) -> Optional[Dict[str, Any]]: + """ + POST /api/v2/content/live/source/get and return the parsed JSON. + + Requires an active authenticated session; the customer_id, + customer_profile_id and device_id are read from the auth token via + get_session_info(). + """ + session = self.authenticator.get_session_info() + if not session: + # Force re-authentication and retry once + logger.info("move.tv: No session info; re-authenticating before manifest fetch") + self.authenticator.authenticate(force_refresh=True) + session = self.authenticator.get_session_info() + + if not session: + logger.error("move.tv: Unable to obtain session info for manifest fetch") + return None + + payload = { + "customerId": session["customer_id"], + "customerProfileId": session["customer_profile_id"], + "liveId": live_id, + "dtype": MoveTVConfig.DTYPE_DASH, + "appVersion": MoveTVConfig.APP_VERSION, + } + + headers = MoveTVConfig.get_api_headers(auth_token=session["auth_token"]) + + logger.debug(f"move.tv: POST {MoveTVConfig.live_source_url()} liveId={live_id}") + + response = self.http_manager.post( + MoveTVConfig.live_source_url(), + operation="manifest", + json=payload, + headers=headers, + ) + response.raise_for_status() + data = response.json() + + if not data.get("success"): + logger.warning( + f"move.tv: live source endpoint returned success=false for liveId={live_id}" + ) + return None + + return data + + def _channel_by_id(self, content_id: str) -> Optional[MoveTVChannel]: + """Return the cached MoveTVChannel whose content_id matches, or None.""" + for ch in (self.channels or []): + if isinstance(ch, MoveTVChannel) and ch.content_id == content_id: + return ch + return None + + def _store_play_auth(self, content_id: str, source_data: Dict[str, Any]) -> None: + """Cache the X-Play-Auth header value on the matching channel.""" + protection: Dict = source_data.get("protection", {}) + header_value: str = protection.get("headerValue", "") + if not header_value: + return + channel = self._channel_by_id(content_id) + if channel: + channel.play_auth_header = header_value + logger.debug( + f"move.tv: Cached X-Play-Auth for liveId={content_id}: " + f"{header_value[:60]}…" + ) \ No newline at end of file