Add livgolf

This commit is contained in:
Nirvana
2026-04-20 18:42:19 +02:00
parent d5d9b9e06b
commit e8ecaf8b8e
3 changed files with 96 additions and 86 deletions
@@ -7,14 +7,14 @@ from .constants import (
PROVIDER_LOGO,
PROVIDER_NAME,
)
from .event_manager import LivGolfEventManager
from .channel_manager import LivGolfChannelManager
from .provider import LivGolfProvider
__all__ = [
"LivGolfProvider",
"LivGolfAuthenticator",
"LivGolfAuthToken",
"LivGolfEventManager",
"LivGolfChannelManager",
"PROVIDER_NAME",
"PROVIDER_LABEL",
"PROVIDER_LOGO",
@@ -22,4 +22,4 @@ __all__ = [
"DEFAULT_CHAMPION_ID",
]
__version__ = "1.0.0"
__version__ = "1.0.0"
@@ -1,22 +1,22 @@
# streaming_providers/providers/livgolf/event_manager.py
# streaming_providers/providers/livgolf/channel_manager.py
# -*- coding: utf-8 -*-
"""
Event manager for the LIV Golf provider.
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 ``Event`` objects ready for the provider.
* 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_events()`` call so that live
* Stream lists are fetched fresh on every ``get_channels()`` call so that live
tournament URLs (which rotate) are always current.
* No EPG, no channels this provider is events-only.
* No EPG support this provider is channels-only.
"""
from __future__ import annotations
@@ -24,7 +24,7 @@ from __future__ import annotations
import re
from typing import Any, Dict, List, Optional
from ...base.models import Event
from ...base.models import StreamingChannel
from ...base.utils.logger import logger
from .constants import (
API_ENDPOINTS,
@@ -61,12 +61,12 @@ def _rewrite_cdn(url: str, preferred_base: str) -> str:
# ---------------------------------------------------------------------------
# LivGolfEventManager
# LivGolfChannelManager
# ---------------------------------------------------------------------------
class LivGolfEventManager:
class LivGolfChannelManager:
"""
Fetches LIV Golf live event streams and normalises them into ``Event`` objects.
Fetches LIV Golf live camera streams and normalises them into ``StreamingChannel`` objects.
Parameters
----------
@@ -83,21 +83,21 @@ class LivGolfEventManager:
# Cached preferred CDN base URL (None = not yet resolved)
self._preferred_cdn: Optional[str] = None
# Cache for events keyed by content_id (video_id)
self._events_cache: Dict[str, Event] = {}
# Cache for channels keyed by content_id (video_id)
self._channels_cache: Dict[str, StreamingChannel] = {}
logger.info("[LivGolfEventManager] Initialised")
logger.info("[LivGolfChannelManager] Initialised")
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def get_events(self, champion_id: str = DEFAULT_CHAMPION_ID) -> List[Event]:
def get_channels(self, champion_id: str = DEFAULT_CHAMPION_ID) -> List[StreamingChannel]:
"""
Return all available live streams for *champion_id* as ``Event`` objects.
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 Event with a DASH manifest URL rewritten to
Each stream becomes one Channel with a DASH manifest URL rewritten to
the closest CDN region.
Parameters
@@ -107,7 +107,7 @@ class LivGolfEventManager:
"""
authorization = self._auth.get_authorization_header()
if not authorization:
logger.error("[LivGolfEventManager] No authorization token available")
logger.error("[LivGolfChannelManager] No authorization token available")
return []
preferred_cdn = self._get_preferred_cdn(authorization)
@@ -124,50 +124,50 @@ class LivGolfEventManager:
stream_kind="group",
)
events: List[Event] = []
new_cache: Dict[str, Event] = {}
channels: List[StreamingChannel] = []
new_cache: Dict[str, StreamingChannel] = {}
for stream in team_streams:
event = self._build_event(stream, preferred_cdn, stream_kind="team")
if event:
events.append(event)
new_cache[event.content_id] = event
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:
event = self._build_event(stream, preferred_cdn, stream_kind="group")
if event:
events.append(event)
new_cache[event.content_id] = event
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._events_cache = new_cache
self._channels_cache = new_cache
logger.info(
f"[LivGolfEventManager] champion={champion_id}: "
f"[LivGolfChannelManager] champion={champion_id}: "
f"{len(team_streams)} team + {len(group_streams)} group streams → "
f"{len(events)} events"
f"{len(channels)} channels"
)
return events
return channels
def get_event(
def get_channel(
self, content_id: str, champion_id: str = DEFAULT_CHAMPION_ID
) -> Optional[Event]:
) -> Optional[StreamingChannel]:
"""
Return a single Event by its content_id.
Return a single StreamingChannel by its content_id.
If the event is not in the cache, it triggers a fresh get_events() call
If the channel is not in the cache, it triggers a fresh get_channels() call
to discover it.
"""
event = self._events_cache.get(content_id)
if event:
return event
channel = self._channels_cache.get(content_id)
if channel:
return channel
logger.info(
f"[LivGolfEventManager] Event '{content_id}' not in cache — "
f"[LivGolfChannelManager] Channel '{content_id}' not in cache — "
"triggering fresh fetch"
)
self.get_events(champion_id=champion_id)
return self._events_cache.get(content_id)
self.get_channels(champion_id=champion_id)
return self._channels_cache.get(content_id)
# ------------------------------------------------------------------
# Region / CDN resolution
@@ -185,7 +185,7 @@ class LivGolfEventManager:
return self._preferred_cdn
self._preferred_cdn = self._resolve_preferred_cdn(authorization)
logger.info(f"[LivGolfEventManager] Preferred CDN: {self._preferred_cdn}")
logger.info(f"[LivGolfChannelManager] Preferred CDN: {self._preferred_cdn}")
return self._preferred_cdn
def _resolve_preferred_cdn(self, authorization: str) -> str:
@@ -205,7 +205,7 @@ class LivGolfEventManager:
data = response.json()
except Exception as exc:
logger.warning(
f"[LivGolfEventManager] Regions fetch failed, using fallback: {exc}"
f"[LivGolfChannelManager] Regions fetch failed, using fallback: {exc}"
)
return FALLBACK_CDN_BASE
@@ -221,7 +221,7 @@ class LivGolfEventManager:
break
if not region_map:
logger.warning("[LivGolfEventManager] Empty region map, using fallback CDN")
logger.warning("[LivGolfChannelManager] Empty region map, using fallback CDN")
return FALLBACK_CDN_BASE
# Walk the preference list and return the first available region
@@ -232,7 +232,7 @@ class LivGolfEventManager:
# Fall back to the first returned region
first_base = next(iter(region_map.values()))
logger.warning(
f"[LivGolfEventManager] No preferred region matched — "
f"[LivGolfChannelManager] No preferred region matched — "
f"using first available: {first_base}"
)
return first_base
@@ -264,27 +264,27 @@ class LivGolfEventManager:
data = response.json()
streams = data.get("streams", [])
logger.debug(
f"[LivGolfEventManager] Fetched {len(streams)} {stream_kind} streams"
f"[LivGolfChannelManager] Fetched {len(streams)} {stream_kind} streams"
)
return streams
except Exception as exc:
logger.warning(
f"[LivGolfEventManager] Failed to fetch {stream_kind} streams: {exc}"
f"[LivGolfChannelManager] Failed to fetch {stream_kind} streams: {exc}"
)
return []
# ------------------------------------------------------------------
# Event construction
# Channel construction
# ------------------------------------------------------------------
def _build_event(
def _build_channel(
self,
stream: Dict[str, Any],
preferred_cdn: str,
stream_kind: str,
) -> Optional[Event]:
) -> Optional[StreamingChannel]:
"""
Convert a single stream dict into an ``Event``.
Convert a single stream dict into a ``StreamingChannel``.
Stream dicts have at least: ``id``, ``name``, ``dashUrl``, ``hlsUrl``.
Team streams additionally carry ``teamId`` and ``livTeamId``.
@@ -294,14 +294,14 @@ class LivGolfEventManager:
if not video_id or not raw_name:
logger.debug(
f"[LivGolfEventManager] Skipping stream with missing id or name: {stream}"
f"[LivGolfChannelManager] Skipping stream with missing id or name: {stream}"
)
return None
dash_url = stream.get("dashUrl", "")
if not dash_url:
logger.debug(
f"[LivGolfEventManager] Skipping stream '{raw_name}' — no DASH URL"
f"[LivGolfChannelManager] Skipping stream '{raw_name}' — no DASH URL"
)
return None
@@ -329,7 +329,7 @@ class LivGolfEventManager:
manifest_script = " ".join(meta_parts)
try:
event = Event(
channel = StreamingChannel(
name=label,
content_id=content_id,
provider=PROVIDER_NAME,
@@ -347,10 +347,10 @@ class LivGolfEventManager:
video="best",
on_demand=False,
)
return event
return channel
except Exception as exc:
logger.warning(
f"[LivGolfEventManager] Failed to construct Event for '{raw_name}': {exc}"
f"[LivGolfChannelManager] Failed to construct Channel for '{raw_name}': {exc}"
)
return None
@@ -367,7 +367,7 @@ class LivGolfEventManager:
"""
Turn a raw API name like ``"Team_06"`` or ``"Group_03"`` into a
presentable label like ``"LIV Golf Team Feed 6"`` or
``"LIV Golf Group Feed 3"``.
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.
@@ -388,4 +388,4 @@ class LivGolfEventManager:
if index is not None:
return f"LIV Golf {kind_label} Feed {index}"
return f"LIV Golf {kind_label} Feed ({raw_name})"
return f"LIV Golf {kind_label} Feed ({raw_name})"
@@ -5,8 +5,8 @@ LIV Golf streaming provider.
Supported features
------------------
* Events (live team and group camera feeds) — no authentication required.
* No channels, no EPG, no catch-up.
* Channels (live team and group camera feeds) — no authentication required.
* No EPG, no events, no catch-up.
Authentication
--------------
@@ -15,7 +15,7 @@ long-lived (~1 year) and is persisted between sessions by the base
authenticator's settings_manager.
"""
from typing import ClassVar, Dict, List, Optional
from typing import ClassVar, Dict, List, Optional, Tuple
from ...base.models import Event, StreamingChannel
from ...base.models.proxy_models import ProxyConfig
@@ -31,14 +31,14 @@ from .constants import (
PROVIDER_NAME,
USER_AGENT,
)
from .event_manager import LivGolfEventManager
from .channel_manager import LivGolfChannelManager
class LivGolfProvider(StreamingProvider):
"""
StreamingProvider implementation for LIV Golf.
Only ``get_events()`` is meaningful — ``get_channels()``, ``get_epg()``,
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.
"""
@@ -78,7 +78,7 @@ class LivGolfProvider(StreamingProvider):
proxy_config=self.http_manager.config.proxy_config,
)
self.event_manager = LivGolfEventManager(
self.channel_manager = LivGolfChannelManager(
http_manager=self.http_manager,
authenticator=self.authenticator,
)
@@ -106,6 +106,11 @@ class LivGolfProvider(StreamingProvider):
# 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
@@ -133,12 +138,12 @@ class LivGolfProvider(StreamingProvider):
return self.authenticate(force_refresh=True)
# ------------------------------------------------------------------
# Events — the sole data surface of this provider
# Channels — the sole data surface of this provider
# ------------------------------------------------------------------
def get_events(self, **kwargs) -> List[Event]:
def get_channels(self, **kwargs) -> List[StreamingChannel]:
"""
Return all live LIV Golf camera feeds as ``Event`` objects.
Return all live LIV Golf camera feeds as ``StreamingChannel`` objects.
Both team-camera streams and group-camera streams are included.
@@ -149,28 +154,28 @@ class LivGolfProvider(StreamingProvider):
itself defaulting to ``DEFAULT_CHAMPION_ID``).
"""
champion_id = kwargs.get("champion_id", self._champion_id)
logger.info(f"[LivGolfProvider] get_events(champion_id={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_events")
logger.info("[LivGolfProvider] Token expired — refreshing before get_channels")
self.authenticate(force_refresh=True)
events = self.event_manager.get_events(champion_id=champion_id)
logger.info(f"[LivGolfProvider] Returning {len(events)} events")
return events
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_events failed: {exc}")
logger.error(f"[LivGolfProvider] get_channels failed: {exc}")
raise
# ------------------------------------------------------------------
# Channels / EPG / manifest — not supported; satisfy base contract
# EPG / Events / manifest — not supported or delegated
# ------------------------------------------------------------------
def get_channels(self, **kwargs) -> List[StreamingChannel]:
"""LIV Golf has no linear channels."""
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]:
@@ -179,23 +184,23 @@ class LivGolfProvider(StreamingProvider):
def get_manifest(self, content_id: str, **kwargs) -> Optional[str]:
"""
Retrieve the manifest URL for a specific event ID.
Retrieve the manifest URL for a specific channel ID.
If the event is not in the event manager's cache, it triggers a fresh
fetch. LIV Golf events embed the manifest URL directly.
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:
event = self.event_manager.get_event(
channel = self.channel_manager.get_channel(
content_id, champion_id=champion_id
)
if event:
if channel:
logger.info(f"[LivGolfProvider] Found manifest for '{content_id}'")
return event.manifest
return channel.manifest
logger.warning(f"[LivGolfProvider] Event '{content_id}' not found")
logger.warning(f"[LivGolfProvider] Channel '{content_id}' not found")
return None
except Exception as exc:
@@ -217,6 +222,11 @@ class LivGolfProvider(StreamingProvider):
) -> Optional[str]:
return None
def enrich_channel_data(
self, channel: StreamingChannel, **kwargs
) -> Optional[StreamingChannel]:
return None
@staticmethod
def validate_credentials() -> bool:
"""
@@ -235,4 +245,4 @@ class LivGolfProvider(StreamingProvider):
@classmethod
def get_static_label(cls, country: str = None) -> str:
return PROVIDER_LABEL
return PROVIDER_LABEL