diff --git a/lib/streaming_providers/providers/movetv/api.py b/lib/streaming_providers/providers/movetv/api.py index ffb632e..e242b98 100644 --- a/lib/streaming_providers/providers/movetv/api.py +++ b/lib/streaming_providers/providers/movetv/api.py @@ -109,7 +109,7 @@ def api_post( response: Any = None for attempt in (1, 2): info = session.info(need_profile=need_profile) # raises typed AuthError & co. - headers = MoveTVConfig.get_api_headers(info["auth_token"]) + headers = MoveTVConfig.get_api_headers(info["auth_token"], dev_id=info.get("uid")) try: response = http_manager.post( url, operation=operation, json=build_payload(info), headers=headers diff --git a/lib/streaming_providers/providers/movetv/auth.py b/lib/streaming_providers/providers/movetv/auth.py index a9960ae..9e0cd16 100644 --- a/lib/streaming_providers/providers/movetv/auth.py +++ b/lib/streaming_providers/providers/movetv/auth.py @@ -96,7 +96,7 @@ class MoveTVAuthenticator(BaseAuthenticator): def _get_auth_headers(self) -> Dict[str, str]: """Headers for the authentication request.""" - return MoveTVConfig.get_base_headers() + return MoveTVConfig.get_base_headers(dev_id=self.get_device_id()) def _build_auth_payload(self) -> Dict[str, Any]: """Build the login payload from current credentials.""" @@ -110,6 +110,7 @@ class MoveTVAuthenticator(BaseAuthenticator): "deviceName": MoveTVConfig.DEVICE_NAME, "deviceModelId": MoveTVConfig.DEVICE_MODEL_ID, "uid": uid, + "debugMac": uid, "appVersion": MoveTVConfig.APP_VERSION, } @@ -217,8 +218,10 @@ class MoveTVAuthenticator(BaseAuthenticator): status, f"move.tv: login failed (HTTP {status})", url=self.auth_endpoint ) from exc if isinstance(status, int) and status >= 400: + body = (getattr(response, "text", "") or "")[:300] + logger.warning(f"move.tv: login failed (HTTP {status}): {body}") raise default_error_for_status_heuristic( - status, f"move.tv: login failed (HTTP {status})", url=self.auth_endpoint + status, f"move.tv: login failed (HTTP {status}): {body}", url=self.auth_endpoint ) try: data = response.json() @@ -255,13 +258,11 @@ class MoveTVAuthenticator(BaseAuthenticator): logger.debug("move.tv: validate_token called with empty access_token, skipping") return False - headers = { - **MoveTVConfig.get_base_headers(), - "x-auth-token": token.access_token, - "x-refresh-token": token.refresh_token or "", - } + headers = MoveTVConfig.get_base_headers(token.access_token, dev_id=token.device_id) + headers[MoveTVConfig.HEADER_REFRESH_TOKEN] = token.refresh_token or "" payload = { "customerId": token.customer_id, + "debugMac": token.device_id, "appVersion": MoveTVConfig.APP_VERSION, } @@ -279,7 +280,8 @@ class MoveTVAuthenticator(BaseAuthenticator): response.raise_for_status() data = response.json() except Exception as exc: - logger.debug(f"move.tv: Token validation request failed: {exc}") + status = getattr(getattr(exc, "response", None), "status_code", None) + logger.warning(f"move.tv: Token validation request failed (HTTP {status}): {exc}") return False if data.get("message") == "Token active.": @@ -312,10 +314,8 @@ class MoveTVAuthenticator(BaseAuthenticator): 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, - } + headers = MoveTVConfig.get_base_headers(dev_id=token.device_id) + headers[MoveTVConfig.HEADER_REFRESH_TOKEN] = token.refresh_token payload = { "customerProfileId": token.customer_profile_id, "appVersion": MoveTVConfig.APP_VERSION, diff --git a/lib/streaming_providers/providers/movetv/constants.py b/lib/streaming_providers/providers/movetv/constants.py index 15ddbe5..2de3f53 100644 --- a/lib/streaming_providers/providers/movetv/constants.py +++ b/lib/streaming_providers/providers/movetv/constants.py @@ -58,8 +58,8 @@ class MoveTVConfig: # ------------------------------------------------------------------------- PARTNER_ID: int = 2 DEVICE_MODEL_ID: int = 10 - DEVICE_NAME: str = "Chrome 146" - APP_VERSION: str = "3.4.8" + DEVICE_NAME: str = "Chrome 155" + APP_VERSION: str = "3.5.41" # ------------------------------------------------------------------------- # Language / locale defaults @@ -70,9 +70,9 @@ class MoveTVConfig: # HTTP / request defaults # ------------------------------------------------------------------------- USER_AGENT: str = ( - "Mozilla/5.0 (X11; Linux x86_64) " + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/146.0.0.0 Safari/537.36" + "Chrome/155.0.0.0 Safari/537.36" ) TIMEOUT: int = 30 @@ -96,6 +96,8 @@ class MoveTVConfig: PLAY_AUTH_EARLY_EXPIRY_SECONDS: int = 60 PLAY_AUTH_EXPIRY_PATTERN: str = r"expires-(\d+)" HEADER_PLAY_AUTH: str = "X-Play-Auth" + HEADER_DEV_ID: str = "X-Dev-Id" + HEADER_REFRESH_TOKEN: str = "X-Refresh-Token" # VOD: browse tag per content-type NAME (as in v1) TAG_ID_BY_CONTENT_TYPE_NAME: Dict[str, int] = {"Film": 1, "Serija": 2} @@ -135,27 +137,36 @@ class MoveTVConfig: # ------------------------------------------------------------------------- @classmethod - def get_base_headers(cls, auth_token: Optional[str] = None) -> Dict[str, str]: + def get_base_headers( + cls, auth_token: Optional[str] = None, dev_id: 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). + (as observed in the login request capture). *dev_id* is the local + device uid; the web client sends it as X-Dev-Id on every call. """ - return { + headers = { "Accept": "*/*", - "Accept-Encoding": "gzip, deflate, br, zstd", + # Only encodings the HTTP stack is sure to decode (no br / zstd). + "Accept-Encoding": "gzip, deflate", "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, } + if dev_id: + headers[cls.HEADER_DEV_ID] = dev_id + return headers @classmethod - def get_api_headers(cls, auth_token: Optional[str] = None) -> Dict[str, str]: + def get_api_headers( + cls, auth_token: Optional[str] = None, dev_id: Optional[str] = None + ) -> Dict[str, str]: """Headers for authenticated JSON API calls (channels, manifest source, VOD).""" - return cls.get_base_headers(auth_token) + return cls.get_base_headers(auth_token, dev_id=dev_id) @classmethod def stream_headers(cls, play_auth: Optional[str] = None) -> Dict[str, str]: diff --git a/lib/streaming_providers/providers/movetv/session.py b/lib/streaming_providers/providers/movetv/session.py index 95fe0db..b697cc5 100644 --- a/lib/streaming_providers/providers/movetv/session.py +++ b/lib/streaming_providers/providers/movetv/session.py @@ -67,10 +67,13 @@ class MoveTvSession: return self.authenticator.get_current_token() def _ready(self) -> bool: + # No local is_expired check: the token's real lifetime is decided by + # the server (401 -> recover()). A locally "expired" but server-valid + # token would otherwise be re-validated on every call. token = self._token() return bool( self._logged_in and token is not None - and getattr(token, "access_token", None) and not token.is_expired + and getattr(token, "access_token", None) ) # ---- failure bookkeeping --------------------------------------------- @@ -165,7 +168,9 @@ class MoveTvSession: return self._token().access_token def build_headers(self) -> Dict[str, str]: - return self.config.get_api_headers(self.get_access_token()) + access_token = self.get_access_token() + dev_id = getattr(self._token(), "device_id", None) + return self.config.get_api_headers(access_token, dev_id=dev_id) def invalidate(self) -> None: self.authenticator.invalidate_token() # memory AND persisted storage