Add movetv

This commit is contained in:
Nirvana
2026-03-25 11:14:10 +01:00
parent 4e9673edd2
commit 8a3f3bbdb2
3 changed files with 851 additions and 0 deletions
@@ -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]}"
)