diff --git a/lib/streaming_providers/providers/livgolf/__init__.py b/lib/streaming_providers/providers/livgolf/__init__.py deleted file mode 100644 index 14c05b5..0000000 --- a/lib/streaming_providers/providers/livgolf/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -# streaming_providers/providers/livgolf/__init__.py -from .auth import LivGolfAuthToken, LivGolfAuthenticator -from .constants import ( - API_ENDPOINTS, - DEFAULT_CHAMPION_ID, - PROVIDER_LABEL, - PROVIDER_LOGO, - PROVIDER_NAME, -) -from .channel_manager import LivGolfChannelManager -from .provider import LivGolfProvider - -__all__ = [ - "LivGolfProvider", - "LivGolfAuthenticator", - "LivGolfAuthToken", - "LivGolfChannelManager", - "PROVIDER_NAME", - "PROVIDER_LABEL", - "PROVIDER_LOGO", - "API_ENDPOINTS", - "DEFAULT_CHAMPION_ID", -] - -__version__ = "1.0.0" diff --git a/lib/streaming_providers/providers/livgolf/auth.py b/lib/streaming_providers/providers/livgolf/auth.py deleted file mode 100644 index f6c0df1..0000000 --- a/lib/streaming_providers/providers/livgolf/auth.py +++ /dev/null @@ -1,275 +0,0 @@ -# streaming_providers/providers/livgolf/auth.py -# -*- coding: utf-8 -*- -""" -Anonymous authentication for the LIV Golf / ViewLift API. - -The flow is intentionally simple: - 1. Generate a stable ``browser-`` device ID (persisted in the token). - 2. POST to the anonymous-token endpoint — no credentials required. - 3. The response contains a JWT ``authorizationToken`` valid for ~1 year. - 4. On every subsequent request supply it as the ``Authorization`` header. - -Token persistence reuses the BaseAuthenticator / BaseAuthToken machinery so -that the token survives process restarts (stored via settings_manager). -""" - -import json -import time -import uuid -import base64 -from dataclasses import dataclass, field -from typing import Any, Dict, Optional - -from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel -from ...base.auth.credentials import ClientCredentials -from ...base.models.proxy_models import ProxyConfig -from ...base.utils.logger import logger -from .constants import ( - API_ENDPOINTS, - DEFAULT_REQUEST_TIMEOUT, - PLATFORM, - PROVIDER_NAME, - SITE, - get_base_headers, -) - - -# --------------------------------------------------------------------------- -# JWT helpers (no external dependency — same pattern as magentaeu) -# --------------------------------------------------------------------------- - -def _base64url_decode(s: str) -> bytes: - padding = "=" * (4 - len(s) % 4) - return base64.urlsafe_b64decode(s + padding) - - -def _decode_jwt_payload(token: str) -> Dict[str, Any]: - """Decode JWT payload without signature verification.""" - try: - _, payload_b64, _ = token.split(".") - return json.loads(_base64url_decode(payload_b64).decode("utf-8")) - except Exception as exc: - raise ValueError(f"Cannot decode JWT: {exc}") from exc - - -def _token_expires_at(token: str) -> float: - """Return the ``exp`` claim as a Unix timestamp, or 0 on failure.""" - try: - return float(_decode_jwt_payload(token).get("exp", 0)) - except Exception: - return 0.0 - - -# --------------------------------------------------------------------------- -# Token dataclass -# --------------------------------------------------------------------------- - -@dataclass -class LivGolfAuthToken(BaseAuthToken): - """ - Thin wrapper around BaseAuthToken that adds the ``device_id`` used when - requesting the anonymous token (needed to stay consistent across refreshes). - """ - - device_id: str = field(default="") - - # ------------------------------------------------------------------ - # Serialisation — must round-trip through BaseAuthenticator's - # settings_manager.save_session / load_session machinery. - # ------------------------------------------------------------------ - - 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, - "auth_level": ( - self.auth_level.value if self.auth_level else TokenAuthLevel.CLIENT_CREDENTIALS.value - ), - "credential_type": self.credential_type or "client_credentials", - "device_id": self.device_id, - } - - -# --------------------------------------------------------------------------- -# Authenticator -# --------------------------------------------------------------------------- - -class LivGolfAuthenticator(BaseAuthenticator): - """ - Anonymous-only authenticator for the LIV Golf ViewLift API. - - No username / password are required or supported. The token is a long-lived - JWT (~1 year) tied to a generated ``browser-`` device ID. - """ - - def __init__( - self, - config_dir: Optional[str] = None, - http_manager=None, - proxy_config: Optional[ProxyConfig] = None, - ) -> None: - if http_manager is None: - raise ValueError("http_manager is required for LivGolfAuthenticator") - - self._http_manager = http_manager - self._proxy_config = proxy_config - - # BaseAuthenticator.__init__ loads any persisted session automatically. - super().__init__( - provider_name=PROVIDER_NAME, - config_dir=config_dir, - # No credentials needed for anonymous auth - credentials=None, - # No Kodi integration required - enable_kodi_integration=False, - ) - - # Ensure we always have a device_id — either restored from disk or freshly generated. - if not self._current_token or not isinstance(self._current_token, LivGolfAuthToken): - device_id = f"browser-{uuid.uuid4()}" - self._current_token = LivGolfAuthToken( - access_token="", - token_type="Bearer", - expires_in=0, - issued_at=time.time(), - device_id=device_id, - ) - logger.debug(f"[LivGolfAuthenticator] Generated new device_id: {device_id}") - else: - logger.debug( - f"[LivGolfAuthenticator] Restored device_id: {self._current_token.device_id}" - ) - - # ------------------------------------------------------------------ - # BaseAuthenticator abstract-method implementations - # ------------------------------------------------------------------ - - @property - def auth_endpoint(self) -> str: - """Anonymous token endpoint — device_id filled in at call time.""" - return API_ENDPOINTS["ANONYMOUS_TOKEN"] - - def _get_auth_headers(self) -> Dict[str, str]: - return get_base_headers() - - def _build_auth_payload(self) -> Dict[str, Any]: - # Anonymous token is obtained via GET — no POST body needed. - return {} - - def get_fallback_credentials(self): - # Anonymous authenticator - treat as client credentials - return ClientCredentials(client_id=PROVIDER_NAME, client_secret="anonymous") - - def _perform_authentication(self) -> BaseAuthToken: - """Fetch a fresh anonymous token from the ViewLift identity endpoint.""" - device_id = self._device_id - - url = API_ENDPOINTS["ANONYMOUS_TOKEN"].format( - site=SITE, - platform=PLATFORM, - device_id=device_id, - ) - - logger.info(f"[LivGolfAuthenticator] Requesting anonymous token (device_id={device_id})") - - response = self._http_manager.get( - url, - operation="auth_anonymous", - headers=get_base_headers(), - timeout=DEFAULT_REQUEST_TIMEOUT, - ) - response.raise_for_status() - - data = response.json() - raw_token = data.get("authorizationToken") - if not raw_token: - raise ValueError( - f"Anonymous token response missing 'authorizationToken'. " - f"Keys received: {list(data.keys())}" - ) - - exp = _token_expires_at(raw_token) - issued = time.time() - expires_in = max(0, int(exp - issued)) if exp else 86400 * 365 - - token = LivGolfAuthToken( - access_token=raw_token, - token_type="Bearer", - expires_in=expires_in, - issued_at=issued, - device_id=device_id, - auth_level=TokenAuthLevel.CLIENT_CREDENTIALS, - ) - - logger.info( - f"[LivGolfAuthenticator] Anonymous token obtained " - f"(expires_in={expires_in}s, device_id={device_id})" - ) - return token - - def _refresh_token(self) -> Optional[BaseAuthToken]: - """ - ViewLift anonymous tokens are very long-lived (~1 year), but when they - do expire we simply request a new one — same device_id, new JWT. - """ - logger.info("[LivGolfAuthenticator] Refreshing anonymous token") - try: - return self._perform_authentication() - except Exception as exc: - logger.warning(f"[LivGolfAuthenticator] Token refresh failed: {exc}") - return None - - def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel: - return TokenAuthLevel.CLIENT_CREDENTIALS - - def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken: - """Restore a token from the persisted session dict.""" - raw_token = response_data.get("access_token", "") - device_id = response_data.get("device_id") or f"browser-{uuid.uuid4()}" - - issued = response_data.get("issued_at", time.time()) - expires_in = response_data.get("expires_in", 0) - - token = LivGolfAuthToken( - access_token=raw_token, - token_type=response_data.get("token_type", "Bearer"), - expires_in=expires_in, - issued_at=issued, - device_id=device_id, - auth_level=TokenAuthLevel.CLIENT_CREDENTIALS, - ) - return token - - # ------------------------------------------------------------------ - # Public helpers - # ------------------------------------------------------------------ - - @property - def _device_id(self) -> str: - """Return the stable device ID, regardless of token state.""" - if self._current_token and isinstance(self._current_token, LivGolfAuthToken): - return self._current_token.device_id or f"browser-{uuid.uuid4()}" - return f"browser-{uuid.uuid4()}" - - def get_authorization_header(self, force_refresh: bool = False) -> str: - """ - Return a ready-to-use ``Authorization`` header value (the raw JWT, - *without* a ``Bearer `` prefix — the LIV Golf API uses the raw token). - """ - token = self.get_bearer_token(force_refresh=force_refresh) - # get_bearer_token may prepend "Bearer " — strip it for this API - if token and token.startswith("Bearer "): - return token[len("Bearer "):] - return token or "" - - def is_token_expired(self) -> bool: - """True if the current token is missing or within the expiry margin.""" - if not self._current_token or not self._current_token.access_token: - return True - exp = _token_expires_at(self._current_token.access_token) - if exp == 0: - return False # Cannot determine expiry — assume valid - from .constants import TOKEN_EXPIRY_MARGIN_SECONDS - return time.time() >= (exp - TOKEN_EXPIRY_MARGIN_SECONDS) \ No newline at end of file diff --git a/lib/streaming_providers/providers/livgolf/channel_manager.py b/lib/streaming_providers/providers/livgolf/channel_manager.py deleted file mode 100644 index 79ef03d..0000000 --- a/lib/streaming_providers/providers/livgolf/channel_manager.py +++ /dev/null @@ -1,391 +0,0 @@ -# streaming_providers/providers/livgolf/channel_manager.py -# -*- coding: utf-8 -*- -""" -Channel manager for the LIV Golf provider. - -Responsibilities ----------------- -* Discover the best CDN edge region from /mobii/regions. -* Fetch team-camera and group-camera stream lists for a champion (tournament). -* Rewrite manifest URLs to use the preferred regional CDN. -* Return normalised ``StreamingChannel`` objects ready for the provider. - -Design notes ------------- -* Region selection is cached for the lifetime of the process — CDN topology - does not change during a session. -* Stream lists are fetched fresh on every ``get_channels()`` call so that live - tournament URLs (which rotate) are always current. -* No EPG support — this provider is channels-only. -""" - -from __future__ import annotations - -import re -from typing import Any, Dict, List, Optional - -from ...base.models import StreamingChannel -from ...base.utils.logger import logger -from .constants import ( - API_ENDPOINTS, - CONTENT_TYPE_LIVE, - DEFAULT_CHAMPION_ID, - DEFAULT_REQUEST_TIMEOUT, - FALLBACK_CDN_BASE, - PROVIDER_NAME, - REGION_PREFERENCE_ORDER, - STREAMING_FORMAT_DASH, - get_authenticated_headers, -) - - -# --------------------------------------------------------------------------- -# Internal helpers -# --------------------------------------------------------------------------- - -# Matches the CDN host in a mobii.com manifest URL, e.g.: -# https://gcp-edge-us-c.mobii.com/api/video/... -_CDN_HOST_RE = re.compile(r"https://([^/]+\.mobii\.com)/") - - -def _rewrite_cdn(url: str, preferred_base: str) -> str: - """ - Replace the CDN host in a mobii.com manifest URL with ``preferred_base``. - - ``preferred_base`` is the full origin, e.g. ``https://gcp-edge-eu-w.mobii.com``. - Returns the original URL unchanged if it does not match the expected pattern. - """ - if not url: - return url - return _CDN_HOST_RE.sub(preferred_base.rstrip("/") + "/", url, count=1) - - -# --------------------------------------------------------------------------- -# LivGolfChannelManager -# --------------------------------------------------------------------------- - -class LivGolfChannelManager: - """ - Fetches LIV Golf live camera streams and normalises them into ``StreamingChannel`` objects. - - Parameters - ---------- - http_manager: - The provider's shared HTTPManager instance. - authenticator: - ``LivGolfAuthenticator`` — used to obtain the current anonymous token. - """ - - def __init__(self, http_manager: Any, authenticator: Any) -> None: - self._http = http_manager - self._auth = authenticator - - # Cached preferred CDN base URL (None = not yet resolved) - self._preferred_cdn: Optional[str] = None - - # Cache for channels keyed by content_id (video_id) - self._channels_cache: Dict[str, StreamingChannel] = {} - - logger.info("[LivGolfChannelManager] Initialised") - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - def get_channels(self, champion_id: str = DEFAULT_CHAMPION_ID) -> List[StreamingChannel]: - """ - Return all available live streams for *champion_id* as ``StreamingChannel`` objects. - - Both team-camera and group-camera feeds are fetched and merged. - Each stream becomes one Channel with a DASH manifest URL rewritten to - the closest CDN region. - - Parameters - ---------- - champion_id: - The LIV Golf tournament/champion identifier (default: ``"59"``). - """ - authorization = self._auth.get_authorization_header() - if not authorization: - logger.error("[LivGolfChannelManager] No authorization token available") - return [] - - preferred_cdn = self._get_preferred_cdn(authorization) - headers = get_authenticated_headers(authorization) - - team_streams = self._fetch_streams( - API_ENDPOINTS["TEAM_STREAMS"].format(champion_id=champion_id), - headers, - stream_kind="team", - ) - group_streams = self._fetch_streams( - API_ENDPOINTS["GROUP_STREAMS"].format(champion_id=champion_id), - headers, - stream_kind="group", - ) - - channels: List[StreamingChannel] = [] - new_cache: Dict[str, StreamingChannel] = {} - - for stream in team_streams: - channel = self._build_channel(stream, preferred_cdn, stream_kind="team") - if channel: - channels.append(channel) - new_cache[channel.content_id] = channel - - for stream in group_streams: - channel = self._build_channel(stream, preferred_cdn, stream_kind="group") - if channel: - channels.append(channel) - new_cache[channel.content_id] = channel - - # Update cache with fresh results - self._channels_cache = new_cache - - logger.info( - f"[LivGolfChannelManager] champion={champion_id}: " - f"{len(team_streams)} team + {len(group_streams)} group streams → " - f"{len(channels)} channels" - ) - return channels - - def get_channel( - self, content_id: str, champion_id: str = DEFAULT_CHAMPION_ID - ) -> Optional[StreamingChannel]: - """ - Return a single StreamingChannel by its content_id. - - If the channel is not in the cache, it triggers a fresh get_channels() call - to discover it. - """ - channel = self._channels_cache.get(content_id) - if channel: - return channel - - logger.info( - f"[LivGolfChannelManager] Channel '{content_id}' not in cache — " - "triggering fresh fetch" - ) - self.get_channels(champion_id=champion_id) - return self._channels_cache.get(content_id) - - # ------------------------------------------------------------------ - # Region / CDN resolution - # ------------------------------------------------------------------ - - def _get_preferred_cdn(self, authorization: str) -> str: - """ - Return the base URL for the closest CDN region. - - Result is cached after the first successful call. Falls back to - ``FALLBACK_CDN_BASE`` if the regions endpoint is unreachable or returns - unexpected data. - """ - if self._preferred_cdn: - return self._preferred_cdn - - self._preferred_cdn = self._resolve_preferred_cdn(authorization) - logger.info(f"[LivGolfChannelManager] Preferred CDN: {self._preferred_cdn}") - return self._preferred_cdn - - def _resolve_preferred_cdn(self, authorization: str) -> str: - """ - Fetch /mobii/regions and select the best regional base URL according - to ``REGION_PREFERENCE_ORDER``. - """ - try: - headers = get_authenticated_headers(authorization) - response = self._http.get( - API_ENDPOINTS["REGIONS"], - operation="regions", - headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT, - ) - response.raise_for_status() - data = response.json() - except Exception as exc: - logger.warning( - f"[LivGolfChannelManager] Regions fetch failed, using fallback: {exc}" - ) - return FALLBACK_CDN_BASE - - # Build a lookup: abbreviation → default uri - region_map: Dict[str, str] = {} - for region in data.get("uris", []): - abbrev = region.get("abbreviation", "") - for uri_entry in region.get("uris", []): - if uri_entry.get("isDefault"): - base = uri_entry.get("uri", "").rstrip("/") - if base: - region_map[abbrev] = base - break - - if not region_map: - logger.warning("[LivGolfChannelManager] Empty region map, using fallback CDN") - return FALLBACK_CDN_BASE - - # Walk the preference list and return the first available region - for preferred in REGION_PREFERENCE_ORDER: - if preferred in region_map: - return region_map[preferred] - - # Fall back to the first returned region - first_base = next(iter(region_map.values())) - logger.warning( - f"[LivGolfChannelManager] No preferred region matched — " - f"using first available: {first_base}" - ) - return first_base - - # ------------------------------------------------------------------ - # Stream fetching - # ------------------------------------------------------------------ - - def _fetch_streams( - self, - url: str, - headers: Dict[str, str], - stream_kind: str, - ) -> List[Dict[str, Any]]: - """ - Fetch a team or group stream list from the API. - - Returns the list of stream dicts from the response, or an empty list - on any error. - """ - try: - response = self._http.get( - url, - operation=f"streams_{stream_kind}", - headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT, - ) - response.raise_for_status() - data = response.json() - streams = data.get("streams", []) - logger.debug( - f"[LivGolfChannelManager] Fetched {len(streams)} {stream_kind} streams" - ) - return streams - except Exception as exc: - logger.warning( - f"[LivGolfChannelManager] Failed to fetch {stream_kind} streams: {exc}" - ) - return [] - - # ------------------------------------------------------------------ - # Channel construction - # ------------------------------------------------------------------ - - def _build_channel( - self, - stream: Dict[str, Any], - preferred_cdn: str, - stream_kind: str, - ) -> Optional[StreamingChannel]: - """ - Convert a single stream dict into a ``StreamingChannel``. - - Stream dicts have at least: ``id``, ``name``, ``dashUrl``, ``hlsUrl``. - Team streams additionally carry ``teamId`` and ``livTeamId``. - """ - video_id = stream.get("id") - raw_name = stream.get("name", "") - - if not video_id or not raw_name: - logger.debug( - f"[LivGolfChannelManager] Skipping stream with missing id or name: {stream}" - ) - return None - - dash_url = stream.get("dashUrl", "") - if not dash_url: - logger.debug( - f"[LivGolfChannelManager] Skipping stream '{raw_name}' — no DASH URL" - ) - return None - - # Rewrite the CDN host to the preferred region - manifest = _rewrite_cdn(dash_url, preferred_cdn) - - # Build a human-readable name. - # Raw names look like "Team_01" or "Group_06" — normalise them. - label = self._format_stream_name(raw_name, stream, stream_kind) - - # Content ID doubles as the ViewLift video id — uniquely identifies the feed. - content_id = video_id - - # team_id is useful as an external reference; omit if not present. - team_id = stream.get("teamId") - liv_team_id = stream.get("livTeamId") - - # Build an optional metadata note carried in manifest_script (same pattern - # as magentaeu which stuffs chno/epgid/media there). - meta_parts = [f"kind={stream_kind}", f"vid={video_id}"] - if team_id is not None: - meta_parts.append(f"team={team_id}") - if liv_team_id is not None: - meta_parts.append(f"liv_team={liv_team_id}") - manifest_script = " ".join(meta_parts) - - try: - channel = StreamingChannel( - name=label, - content_id=content_id, - provider=PROVIDER_NAME, - manifest=manifest, - manifest_script=manifest_script, - # LIV Golf streams are DRM-free - use_cdm=False, - cdm_type=None, - cdm=None, - cdm_mode="", - streaming_format=STREAMING_FORMAT_DASH, - content_type=CONTENT_TYPE_LIVE, - mode="live", - session_manifest=False, - video="best", - on_demand=False, - ) - return channel - except Exception as exc: - logger.warning( - f"[LivGolfChannelManager] Failed to construct Channel for '{raw_name}': {exc}" - ) - return None - - # ------------------------------------------------------------------ - # Naming helpers - # ------------------------------------------------------------------ - - @staticmethod - def _format_stream_name( - raw_name: str, - stream: Dict[str, Any], - stream_kind: str, - ) -> str: - """ - Turn a raw API name like ``"Team_06"`` or ``"Group_03"`` into a - presentable label like ``"LIV Golf – Team Feed 6"`` or - Found Presentable Label ``"LIV Golf – Group Feed 3"``. - - For team streams the team number is replaced by the teamId where - available, since the numeric suffix is just an ordering index. - """ - try: - # Extract the numeric suffix (e.g. "06" → 6) - suffix = int(raw_name.split("_")[-1]) - except (ValueError, IndexError): - suffix = None - - kind_label = "Team" if stream_kind == "team" else "Group" - - if stream_kind == "team": - team_id = stream.get("teamId") - index = team_id if team_id is not None else suffix - else: - index = suffix - - if index is not None: - return f"LIV Golf – {kind_label} Feed {index}" - return f"LIV Golf – {kind_label} Feed ({raw_name})" diff --git a/lib/streaming_providers/providers/livgolf/constants.py b/lib/streaming_providers/providers/livgolf/constants.py deleted file mode 100644 index 92b776a..0000000 --- a/lib/streaming_providers/providers/livgolf/constants.py +++ /dev/null @@ -1,137 +0,0 @@ -# streaming_providers/providers/livgolf/constants.py -# -*- coding: utf-8 -*- -# ============================================================================ -# LIV Golf Configuration -# ============================================================================ - -from typing import Dict, List - -# ============================================================================ -# Provider Identity -# ============================================================================ - -PROVIDER_NAME = "livgolf" -PROVIDER_LABEL = "LIV Golf" -PROVIDER_LOGO = "https://fr.wikipedia.org/wiki/Fichier:LIVGOLF_logo_v2.png" - -# ============================================================================ -# Application / Device Configuration -# ============================================================================ - -SITE = "liv-golf" -PLATFORM = "web_browser" -DEVICE_TYPE = "web_browser" -CONTENT_CONSUMPTION = "web" - -BROWSER = "Chrome" -BROWSER_VERSION = "147" -OS = "Linux" - -USER_AGENT = ( - f"Mozilla/5.0 (X11; {OS} x86_64) AppleWebKit/537.36 " - f"(KHTML, like Gecko) {BROWSER}/{BROWSER_VERSION}.0.0.0 Safari/537.36" -) - -ORIGIN = "https://www.livgolf.com" -REFERER = "https://www.livgolf.com/" - -# Static API key — present in all browser requests as x-api-key header -API_KEY = "79613f5e-52ac-45e7-a8a0-99ea2beb3540" - -# ============================================================================ -# API Endpoints -# ============================================================================ - -API_BASE_URL = "https://liv-golf.api.viewlift.com" - -API_ENDPOINTS = { - # Anonymous token — no credentials required - "ANONYMOUS_TOKEN": ( - API_BASE_URL - + "/identity/anonymous-token" - + "?site={site}&platform={platform}&deviceId={device_id}" - ), - # Regional CDN clusters — used to pick the closest edge node - "REGIONS": API_BASE_URL + "/v3/content/champions/mobii/regions", - # Team-camera streams for a given champion (tournament) ID - "TEAM_STREAMS": API_BASE_URL + "/v3/content/champions/{champion_id}/team/streams", - # Group-camera streams for a given champion (tournament) ID - "GROUP_STREAMS": API_BASE_URL + "/v3/content/champions/{champion_id}/group/streams", - # Entitlement / stream URL check for a single video id - "ENTITLEMENT": ( - API_BASE_URL - + "/v3/entitlement/video/status" - + "?id={video_id}&deviceType={device_type}&contentConsumption={content_consumption}&ssaiDisable=false" - ), -} - -# Champion ID used when no override is given. -# 59 is the current live tournament champion seen in the captured traffic. -DEFAULT_CHAMPION_ID = "59" - -# ============================================================================ -# Streaming Configuration -# ============================================================================ - -STREAMING_FORMAT_DASH = "dash" -CONTENT_TYPE_LIVE = "LIVE" - -# ============================================================================ -# Regional CDN — preference order for closest-edge selection. -# -# The /mobii/regions endpoint returns a list of regions. We rank them by the -# abbreviation prefix so that, e.g., a European client prefers -# gcp-edge-eu-w > gcp-edge-uk-s > gcp-edge-us-* etc. -# The list below is ordered from "most preferred for EU" to "last resort". -# ============================================================================ - -REGION_PREFERENCE_ORDER: List[str] = [ - "gcp-edge-eu-w", # GCP Europe West (Zurich) — closest for most EU users - "gcp-edge-uk-s", # GCP Europe West (London) - "gcp-edge-me-c", # GCP Middle East (Doha) - "gcp-edge-za-n", # GCP South Africa (Johannesburg) - "gcp-edge-a-m", # GCP Asia South (Mumbai) - "gcp-edge-au-s", # GCP Australia South East - "gcp-edge-sa-e", # GCP South America East - "gcp-edge-us-e", # GCP East US - "gcp-edge-us-c", # GCP Central US — default fallback seen in captures -] - -# Fallback CDN base URL when region discovery fails entirely -FALLBACK_CDN_BASE = "https://gcp-edge-eu-w.mobii.com" - -# ============================================================================ -# Request Configuration -# ============================================================================ - -DEFAULT_REQUEST_TIMEOUT = 30 -DEFAULT_MAX_RETRIES = 3 - -# Token TTL margin — refresh the anonymous token this many seconds before it expires -TOKEN_EXPIRY_MARGIN_SECONDS = 300 - -# ============================================================================ -# Headers helpers -# ============================================================================ - - -def get_base_headers() -> Dict[str, str]: - """Minimal headers required for all LIV Golf API calls.""" - return { - "User-Agent": USER_AGENT, - "Accept": "application/json, text/plain, */*", - "Origin": ORIGIN, - "Referer": REFERER, - } - - -def get_authenticated_headers(authorization: str) -> Dict[str, str]: - """Headers that include the anonymous JWT token and the static API key.""" - headers = get_base_headers() - headers.update( - { - "Authorization": authorization, - "x-api-key": API_KEY, - } - ) - return headers \ No newline at end of file diff --git a/lib/streaming_providers/providers/livgolf/provider.py b/lib/streaming_providers/providers/livgolf/provider.py deleted file mode 100644 index 633842c..0000000 --- a/lib/streaming_providers/providers/livgolf/provider.py +++ /dev/null @@ -1,248 +0,0 @@ -# streaming_providers/providers/livgolf/provider.py -# -*- coding: utf-8 -*- -""" -LIV Golf streaming provider. - -Supported features ------------------- -* Channels (live team and group camera feeds) — no authentication required. -* No EPG, no events, no catch-up. - -Authentication --------------- -Anonymous JWT token via the ViewLift identity endpoint. The token is -long-lived (~1 year) and is persisted between sessions by the base -authenticator's settings_manager. -""" - -from typing import ClassVar, Dict, List, Optional, Tuple - -from ...base.models import Event, StreamingChannel -from ...base.models.proxy_models import ProxyConfig -from ...base.provider import StreamingProvider -from ...base.utils.logger import logger -from .auth import LivGolfAuthenticator -from .constants import ( - DEFAULT_CHAMPION_ID, - DEFAULT_MAX_RETRIES, - DEFAULT_REQUEST_TIMEOUT, - PROVIDER_LABEL, - PROVIDER_LOGO, - PROVIDER_NAME, - USER_AGENT, -) -from .channel_manager import LivGolfChannelManager - - -class LivGolfProvider(StreamingProvider): - """ - StreamingProvider implementation for LIV Golf. - - Only ``get_channels()`` is meaningful — ``get_events()``, ``get_epg()``, - ``get_manifest()``, ``get_catchup_manifest()``, and ``get_drm()`` all - return empty / None, consistent with the base contract. - """ - - PROVIDER_LOGO: ClassVar[str] = PROVIDER_LOGO - SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["global"] - - def __init__( - self, - country: str = "global", - config_dir: Optional[str] = None, - proxy_config: Optional[ProxyConfig] = None, - proxy_url: Optional[str] = None, - # Allow callers to pin a specific tournament; defaults to the live one. - champion_id: str = DEFAULT_CHAMPION_ID, - ) -> None: - logger.info(f"[LivGolfProvider] __init__ START (country={country})") - - # StreamingProvider.__init__ expects a country; LIV Golf is global. - super().__init__(country=country) - - self._champion_id = champion_id - - self.http_manager = self._setup_http_manager( - provider_name=PROVIDER_NAME, - proxy_config=proxy_config, - proxy_url=proxy_url, - config_dir=config_dir, - user_agent=USER_AGENT, - timeout=DEFAULT_REQUEST_TIMEOUT, - max_retries=DEFAULT_MAX_RETRIES, - ) - - self.authenticator = LivGolfAuthenticator( - config_dir=config_dir, - http_manager=self.http_manager, - proxy_config=self.http_manager.config.proxy_config, - ) - - self.channel_manager = LivGolfChannelManager( - http_manager=self.http_manager, - authenticator=self.authenticator, - ) - - logger.info("[LivGolfProvider] __init__ COMPLETE") - - # ------------------------------------------------------------------ - # StreamingProvider identity properties - # ------------------------------------------------------------------ - - @property - def provider_name(self) -> str: - return PROVIDER_NAME - - @property - def provider_label(self) -> str: - return PROVIDER_LABEL - - @property - def provider_logo(self) -> str: - return PROVIDER_LOGO - - @property - def uses_dynamic_manifests(self) -> bool: - # Manifest URLs are stable for the duration of a tournament round. - return False - - @property - def epg_window(self) -> Tuple[int, int]: - # No EPG support. - return 0, 0 - - @property - def catchup_window(self) -> int: - return 0 - - @property - def supported_auth_types(self) -> List[str]: - # Anonymous only — no user credentials accepted. - return ["anonymous"] - - # ------------------------------------------------------------------ - # Authentication - # ------------------------------------------------------------------ - - def authenticate(self, **kwargs) -> str: - """ - Obtain / refresh the anonymous token and return it as a Bearer string. - """ - logger.info("[LivGolfProvider] authenticate() called") - force_refresh = kwargs.get("force_refresh", False) - token = self.authenticator.get_bearer_token(force_refresh=force_refresh) - logger.info("[LivGolfProvider] authenticate() complete") - return token or "" - - def refresh_authentication(self) -> str: - return self.authenticate(force_refresh=True) - - # ------------------------------------------------------------------ - # Channels — the sole data surface of this provider - # ------------------------------------------------------------------ - - def get_channels(self, **kwargs) -> List[StreamingChannel]: - """ - Return all live LIV Golf camera feeds as ``StreamingChannel`` objects. - - Both team-camera streams and group-camera streams are included. - - Keyword Arguments - ----------------- - champion_id : str, optional - Override the tournament champion ID (default: provider-level setting, - itself defaulting to ``DEFAULT_CHAMPION_ID``). - """ - champion_id = kwargs.get("champion_id", self._champion_id) - logger.info(f"[LivGolfProvider] get_channels(champion_id={champion_id})") - - try: - # Ensure we have a valid anonymous token before delegating. - if self.authenticator.is_token_expired(): - logger.info("[LivGolfProvider] Token expired — refreshing before get_channels") - self.authenticate(force_refresh=True) - - channels = self.channel_manager.get_channels(champion_id=champion_id) - logger.info(f"[LivGolfProvider] Returning {len(channels)} channels") - return channels - - except Exception as exc: - logger.error(f"[LivGolfProvider] get_channels failed: {exc}") - raise - - # ------------------------------------------------------------------ - # EPG / Events / manifest — not supported or delegated - # ------------------------------------------------------------------ - - def get_events(self, **kwargs) -> List[Event]: - """LIV Golf has no one-time events; streams are linear channels.""" - return [] - - def get_epg(self, channel_id: str, **kwargs) -> List[Dict]: - """LIV Golf has no EPG.""" - return [] - - def get_manifest(self, content_id: str, **kwargs) -> Optional[str]: - """ - Retrieve the manifest URL for a specific channel ID. - - If the channel is not in the channel manager's cache, it triggers a fresh - fetch. LIV Golf channels embed the manifest URL directly. - """ - champion_id = kwargs.get("champion_id", self._champion_id) - logger.info(f"[LivGolfProvider] get_manifest(content_id={content_id})") - - try: - channel = self.channel_manager.get_channel( - content_id, champion_id=champion_id - ) - if channel: - logger.info(f"[LivGolfProvider] Found manifest for '{content_id}'") - return channel.manifest - - logger.warning(f"[LivGolfProvider] Channel '{content_id}' not found") - return None - - except Exception as exc: - logger.error(f"[LivGolfProvider] get_manifest failed: {exc}") - return None - - def get_catchup_manifest( - self, channel_id: str, start_time: int, end_time: int, **kwargs - ) -> Optional[str]: - """No catch-up support.""" - return None - - def get_drm(self, content_id: str, **kwargs) -> list: - """LIV Golf streams are DRM-free.""" - return [] - - def get_dynamic_manifest_params( - self, channel: StreamingChannel, **kwargs - ) -> Optional[str]: - return None - - def enrich_channel_data( - self, channel: StreamingChannel, **kwargs - ) -> Optional[StreamingChannel]: - return None - - @staticmethod - def validate_credentials() -> bool: - """ - Anonymous providers do not validate user credentials. - Return True so the base class does not block provider setup. - """ - return True - - # ------------------------------------------------------------------ - # Class-level helpers - # ------------------------------------------------------------------ - - @classmethod - def get_static_logo(cls, country: str = None) -> str: - return cls.PROVIDER_LOGO - - @classmethod - def get_static_label(cls, country: str = None) -> str: - return PROVIDER_LABEL diff --git a/resources/settings.xml b/resources/settings.xml index ee4b386..03fba44 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -201,17 +201,6 @@ - - - - - - - - - - -