mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-21 16:42:18 +02:00
Add movetv
This commit is contained in:
@@ -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-<unix>' 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,
|
||||
}
|
||||
@@ -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}"
|
||||
@@ -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]}…"
|
||||
)
|
||||
Reference in New Issue
Block a user