From 7b9fbd2f65e5b74784f98f407793ec74b0a527bb Mon Sep 17 00:00:00 2001 From: Nirvana Date: Fri, 27 Mar 2026 08:50:11 +0100 Subject: [PATCH] Add movetv --- .../providers/movetv/auth.py | 89 ++++++++++++++++++- .../providers/movetv/constants.py | 5 ++ .../providers/movetv/provider.py | 50 ++++++++++- 3 files changed, 140 insertions(+), 4 deletions(-) diff --git a/lib/streaming_providers/providers/movetv/auth.py b/lib/streaming_providers/providers/movetv/auth.py index 86e9e36..c0326cd 100644 --- a/lib/streaming_providers/providers/movetv/auth.py +++ b/lib/streaming_providers/providers/movetv/auth.py @@ -264,9 +264,89 @@ class MoveTVAuthenticator(BaseAuthenticator): return False # ------------------------------------------------------------------ - # Override authenticate() to bypass expiry when session data exists + # Token refresh via /api/v2/token/status # ------------------------------------------------------------------ + def refresh_token(self, token: "MoveTVAuthToken") -> Optional["MoveTVAuthToken"]: + """ + Exchange the current refresh_token for a fresh auth_token + refresh_token + using the ``/api/v2/token/status`` endpoint. + + The API expects the *old* refresh_token in the ``x-refresh-token`` header + and ``customerProfileId`` / ``appVersion`` in the JSON body. On success + it returns new ``auth_token`` and ``refresh_token`` values together with + updated server URLs. + + Returns a new :class:`MoveTVAuthToken` (already persisted) on success, + or ``None`` if the refresh fails (caller should fall back to full login). + """ + if not token.refresh_token: + logger.debug("move.tv: refresh_token called but no refresh_token stored, skipping") + return None + + headers = { + **MoveTVConfig.get_base_headers(), + "x-refresh-token": token.refresh_token, + } + payload = { + "customerProfileId": token.customer_profile_id, + "appVersion": MoveTVConfig.APP_VERSION, + } + + logger.debug( + f"move.tv: Refreshing token for customer_profile_id={token.customer_profile_id}" + ) + + try: + response = self.http_manager.post( + MoveTVConfig.token_status_url(), + json=payload, + headers=headers, + operation="token_refresh", + ) + response.raise_for_status() + data = response.json() + except Exception as exc: + logger.debug(f"move.tv: Token refresh request failed: {exc}") + return None + + if not data.get("success") or data.get("message") != "Token active.": + logger.debug(f"move.tv: Token refresh rejected by server: {data}") + return None + + # Merge refreshed fields into a new token, preserving any fields not + # returned by the status endpoint (e.g. credential_type). + existing = token.to_dict() + merged = { + **existing, + # The status response uses "auth_token"; map to both keys. + "access_token": data.get("auth_token", existing.get("access_token")), + "auth_token": data.get("auth_token", existing.get("auth_token")), + "refresh_token": data.get("refresh_token", existing.get("refresh_token")), + # "t" is the new expires_in value from the status response. + "expires_in": data.get("t", existing.get("expires_in")), + "dedicated_server": data.get("dedicated_server", existing.get("dedicated_server")), + "customer_id": int(data.get("customer_id") or existing.get("customer_id", 0)), + # IMPORTANT: The Move.tv API response field "device_id" is a + # server-assigned device identifier — it is NOT the same as the + # base-class device_id, which is our persistent local UID slot + # (analogous to how the API calls the auth token "auth_token" but + # the base class calls it "access_token"). We must preserve the + # existing uid/device_id so the base class keeps its value intact. + } + + new_token = self._create_token_from_response(merged) + self._current_token = new_token + self._save_session() + + logger.info( + f"move.tv: Token refreshed successfully — " + f"customer_id={new_token.customer_id}, uid={new_token.uid}" + ) + return new_token + + + def authenticate(self, force_refresh: bool = False) -> MoveTVAuthToken: """ Move.tv sessions do not carry an explicit server-side expiry we can @@ -293,7 +373,12 @@ class MoveTVAuthenticator(BaseAuthenticator): if self.validate_token(token): return token - logger.info("move.tv: Stored token failed validation, performing full login") + logger.info("move.tv: Stored token failed validation, attempting token refresh") + refreshed = self.refresh_token(token) + if refreshed: + return refreshed + + logger.info("move.tv: Token refresh failed, performing full login") return self._full_login() def _full_login(self) -> MoveTVAuthToken: diff --git a/lib/streaming_providers/providers/movetv/constants.py b/lib/streaming_providers/providers/movetv/constants.py index 4efbea8..e471e39 100644 --- a/lib/streaming_providers/providers/movetv/constants.py +++ b/lib/streaming_providers/providers/movetv/constants.py @@ -22,6 +22,7 @@ class MoveTVConfig: # ------------------------------------------------------------------------- PATH_LOGIN: str = "/api/v2/login" PATH_VALIDATE: str = "/api/v2/token/validate" + PATH_TOKEN_STATUS: str = "/api/v2/token/status" PATH_LIVE_CHANNELS: str = "/api/v2/content/live/all" PATH_LIVE_SOURCE: str = "/api/v2/content/live/source/get" @@ -100,6 +101,10 @@ class MoveTVConfig: def validate_url(cls) -> str: return f"{cls.API_BASE_URL}{cls.PATH_VALIDATE}" + @classmethod + def token_status_url(cls) -> str: + return f"{cls.API_BASE_URL}{cls.PATH_TOKEN_STATUS}" + @classmethod def channels_url(cls) -> str: return f"{cls.API_BASE_URL}{cls.PATH_LIVE_CHANNELS}" diff --git a/lib/streaming_providers/providers/movetv/provider.py b/lib/streaming_providers/providers/movetv/provider.py index 50ba035..1b260cc 100644 --- a/lib/streaming_providers/providers/movetv/provider.py +++ b/lib/streaming_providers/providers/movetv/provider.py @@ -1,12 +1,12 @@ # streaming_providers/providers/movetv/provider.py import requests -from typing import ClassVar, Dict, List, Optional, Any +from typing import ClassVar, Dict, List, Optional, Any, cast 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 .auth import MoveTVAuthenticator, MoveTVAuthToken from .constants import MoveTVConfig @@ -171,6 +171,28 @@ class MoveTVProvider(StreamingProvider): logger.info(f"move.tv: Loaded {len(channels)} subscribed channels") return channels + except requests.HTTPError as exc: + if exc.response is not None and exc.response.status_code == 401: + logger.info("move.tv: 401 on channel fetch — attempting token refresh …") + try: + token = cast(MoveTVAuthToken, self.authenticator._current_token) + if token and self.authenticator.refresh_token(token): + channels = self._fetch_channels() + self.channels = channels # type: ignore[assignment] + return channels + except Exception: + pass + logger.info("move.tv: Refresh failed, retrying channel fetch after full login …") + else: + logger.error(f"move.tv: HTTP error fetching channels: {exc}") + try: + self.authenticator.authenticate(force_refresh=True) + channels = self._fetch_channels() + 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 requests.RequestException as exc: logger.error(f"move.tv: HTTP error fetching channels: {exc}") try: @@ -414,6 +436,30 @@ class MoveTVProvider(StreamingProvider): json=payload, headers=headers, ) + + # On 401, attempt a token refresh and retry once before giving up. + if response.status_code == 401: + logger.info(f"move.tv: 401 on manifest fetch for liveId={live_id} — attempting token refresh …") + token = cast(MoveTVAuthToken, self.authenticator._current_token) + refreshed = self.authenticator.refresh_token(token) if token else None + if not refreshed: + logger.info("move.tv: Refresh failed, falling back to full login for manifest fetch") + self.authenticator.authenticate(force_refresh=True) + # Rebuild session and headers with the new token. + session = self.authenticator.get_session_info() + if not session: + logger.error("move.tv: Unable to obtain session after token refresh for manifest fetch") + return None + payload["customerId"] = session["customer_id"] + payload["customerProfileId"] = session["customer_profile_id"] + headers = MoveTVConfig.get_api_headers(auth_token=session["auth_token"]) + response = self.http_manager.post( + MoveTVConfig.live_source_url(), + operation="manifest", + json=payload, + headers=headers, + ) + response.raise_for_status() data = response.json()