Add discovery

This commit is contained in:
Nirvana
2026-03-03 11:36:40 +01:00
parent e37d79c84d
commit ea23f7b07e
5 changed files with 85 additions and 92 deletions
@@ -24,7 +24,7 @@ class DiscoveryChannelManager:
Responsibilities:
- Fetching and parsing distribution channels from the CMS /home route
- Maintaining the ``_channels_cache`` (channel_id → DiscoveryChannel)
- Maintaining the ``_channels_cache`` (edit_id → DiscoveryChannel)
- Discovering and caching navigation routes from the /home response graph
into ``_cms_routes`` (route_id → label)
@@ -163,6 +163,27 @@ class DiscoveryChannelManager:
except Exception as e:
logger.warning(f"Could not discover CMS routes at init: {e}")
def get_by_distribution_id(
self, distribution_id: str
) -> Optional[DiscoveryChannel]:
"""
Look up a cached DiscoveryChannel by its CMS distribution UUID.
The cache is keyed by ``edit_id``, so this performs a linear scan
using the ``distribution_id`` stored in ``raw_data``. The channel
list is small (typically < 50), so this is acceptable.
Args:
distribution_id: The distributionChannel UUID from the CMS.
Returns:
Matching DiscoveryChannel, or None if not found.
"""
for channel in self._channels_cache.values():
if channel.raw_data.get("distribution_id") == distribution_id:
return channel
return None
# =========================================================================
# CMS route discovery
# =========================================================================
@@ -278,17 +278,24 @@ class DiscoveryEventManager:
relationships, now_utc, start_dt, end_dt
)
# ---- channel lookup -----------------------------------------
channel_name: Optional[str] = None
# ---- edit_id from distributionChannel cache -----------------
# The distributionChannel relationship carries the CMS UUID
# (distribution_id). The cache is keyed by edit_id, so we
# use the reverse map on the provider to resolve it.
edit_id: Optional[str] = None
dist_channel_ref = (
relationships.get("distributionChannel", {}).get("data", {})
)
dist_channel_id = (
dist_channel_ref.get("id") if dist_channel_ref else None
)
channel_name: Optional[str] = None
if dist_channel_id:
cached = self._channels_cache.get(dist_channel_id)
cached = self._provider.channel_manager.get_by_distribution_id(
dist_channel_id
)
if cached:
edit_id = cached.channel_id # channel_id == edit_id
channel_name = cached.name
else:
logger.debug(
@@ -299,13 +306,13 @@ class DiscoveryEventManager:
# ---- build Event --------------------------------------------
event = Event(
name=attributes.get("name", "Unknown Event"),
content_id=airing.get("id", ""),
content_id=edit_id or airing.get("id", ""),
provider=self._provider.provider_name,
logo_url=None,
mode="live" if status == EventStatus.LIVE else "vod",
session_manifest=False,
manifest_script=None,
cdm=None,
session_manifest=True if edit_id else False,
manifest_script=edit_id,
cdm=edit_id,
content_type="AIRING",
description=attributes.get("description", ""),
genre=None,
@@ -613,7 +620,7 @@ class DiscoveryEventManager:
event = Event(
name=attributes.get("name", "Unknown Event"),
content_id=video_data.get("id", ""),
content_id=edit_id or video_data.get("id", ""),
provider=self._provider.provider_name,
logo_url=logo_url,
mode=(
@@ -621,11 +628,9 @@ class DiscoveryEventManager:
if attributes.get("videoType") == "LIVE"
else "vod"
),
session_manifest=True,
manifest_script=(
f"editid={edit_id}" if edit_id else None
),
cdm=f"editid={edit_id}" if edit_id else None,
session_manifest=True if edit_id else False,
manifest_script=edit_id,
cdm=edit_id,
content_type="EVENT",
description=attributes.get("description", ""),
genre=genre,
@@ -109,24 +109,30 @@ class DiscoveryChannel:
attributes = distribution_data.get("attributes", {})
relationships = distribution_data.get("relationships", {})
# Get edit ID
# edit_id is the playback identifier — used as content_id throughout
edit_data = relationships.get("edit", {}).get("data", {})
edit_id = edit_data.get("id")
# distribution_id is the CMS channel UUID — kept as metadata only
distribution_id = distribution_data.get("id", "")
# Extract logo
logo_url = cls._extract_logo_url(relationships, included_by_id)
raw = distribution_data.copy()
raw["distribution_id"] = distribution_id
return cls(
name=attributes.get("name", "Unknown Channel"),
channel_id=distribution_data.get("id", ""),
channel_id=edit_id or distribution_id, # edit_id is the primary key
edit_id=edit_id,
logo_url=logo_url,
description=attributes.get("description", ""),
mode=StreamingMode.LIVE.value,
session_manifest=True,
manifest_script=f"editid={edit_id}" if edit_id else None,
cdm=f"editid={edit_id}" if edit_id else None,
raw_data=distribution_data.copy(),
manifest_script=distribution_id or None,
cdm=distribution_id or None,
raw_data=raw,
)
@classmethod
@@ -150,23 +156,26 @@ class DiscoveryChannel:
attributes = api_data.get("attributes", {})
relationships = api_data.get("relationships", {})
# Get edit ID (for VOD, the ID itself is often the edit_id)
# For VOD/events, the item ID itself is the edit_id (playback identifier)
edit_id = api_data.get("id")
# Extract logo
logo_url = cls._extract_logo_url(relationships, included_by_id)
raw = api_data.copy()
raw["source_id"] = edit_id # preserve original CMS ID as metadata
channel = cls(
name=attributes.get("name", "Unknown Event"),
channel_id=api_data.get("id", ""),
channel_id=edit_id or "", # edit_id is the primary key
edit_id=edit_id,
logo_url=logo_url,
content_type="VOD",
mode=StreamingMode.VOD.value,
session_manifest=True,
manifest_script=f"editid={edit_id}" if edit_id else None,
cdm=f"editid={edit_id}" if edit_id else None,
raw_data=api_data.copy(),
manifest_script=edit_id or None,
cdm=edit_id or None,
raw_data=raw,
**kwargs
)
@@ -314,13 +323,9 @@ class DiscoveryChannel:
# Transfer raw_data
channel.raw_data = self.raw_data.copy()
# Store edit_id and timestamps in raw_data for later use
if self.edit_id:
channel.raw_data["edit_id"] = self.edit_id
if self.start_time:
channel.raw_data["start_time"] = self.start_time
if self.end_time:
channel.raw_data["end_time"] = self.end_time
# Store distribution_id in raw_data for reference (channels only)
if "distribution_id" in self.raw_data:
channel.raw_data["distribution_id"] = self.raw_data["distribution_id"]
return channel
@@ -21,7 +21,6 @@ from ...base.utils.logger import logger
from .constants import PlatformOS, get_default_capabilities, get_default_device_info, get_drm_request_headers
from .exceptions import ManifestFetchError, PlaybackRestrictedException
from .models import DiscoveryChannel
class DiscoveryPlaybackManager:
@@ -43,11 +42,9 @@ class DiscoveryPlaybackManager:
def __init__(
self,
provider, # DiscoveryProvider — avoid circular import
channels_cache: Dict[str, DiscoveryChannel],
playback_cache: Dict[str, tuple],
):
self._provider = provider
self._channels_cache = channels_cache
# {edit_id: (expiry_timestamp, playback_data)}
self._playback_cache = playback_cache
@@ -63,9 +60,9 @@ class DiscoveryPlaybackManager:
"""
Populate streaming data for a list of StreamingChannel objects.
Looks up each channel's ``edit_id`` in the shared ``_channels_cache``,
fetches (or returns cached) playback info, and attaches the manifest
URL, streaming format, and DRM config to the channel object.
``channel.channel_id`` is the ``edit_id`` (playback identifier), so
it is passed directly to ``get_cached_playback_info`` — no cache
indirection required.
Args:
channels: StreamingChannel objects to populate.
@@ -83,17 +80,10 @@ class DiscoveryPlaybackManager:
while retries < max_retries and not success and not is_restricted:
try:
disco_channel = self._channels_cache.get(channel.channel_id)
if not disco_channel:
logger.warning(
f"Channel {channel.name} not in cache, skipping"
)
break
edit_id = disco_channel.edit_id
edit_id = channel.channel_id
if not edit_id:
logger.warning(
f"No edit_id for channel {channel.name}, skipping"
f"No edit_id (channel_id) for {channel.name}, skipping"
)
break
@@ -120,11 +110,6 @@ class DiscoveryPlaybackManager:
channel.license_url = streaming_data["license_url"]
channel.cdm_type = streaming_data["drm_system"]
if streaming_data["drm_auth"]:
disco_channel.raw_data["drm_auth"] = (
streaming_data["drm_auth"]
)
logger.info(
f"Streaming data populated for: {channel.name}"
)
@@ -319,7 +304,7 @@ class DiscoveryPlaybackManager:
"applicationSessionId": str(uuid.uuid4()),
"userPreferences": {
"videoQuality": "best",
"uiLanguage": f"{self._provider.country}-DE".upper(),
"uiLanguage": f"{self._provider.country.lower()}-{self._provider.country.upper()}",
},
"features": ["mlp"],
}
@@ -35,7 +35,7 @@ from .constants import (
get_user_agent,
)
from .event_manager import DiscoveryEventManager
from .exceptions import ChannelNotFoundError, ManifestFetchError
from .exceptions import ManifestFetchError
from .models import DiscoveryChannel
from .playback_manager import DiscoveryPlaybackManager
@@ -116,7 +116,7 @@ class DiscoveryProvider(StreamingProvider):
# ------------------------------------------------------------------
# Shared caches (owned here; passed by reference into each manager)
# ------------------------------------------------------------------
# DiscoveryChannel objects keyed by channel_id
# DiscoveryChannel objects keyed by edit_id (the playback identifier)
self._channels_cache: Dict[str, DiscoveryChannel] = {}
# Navigation routes discovered from /home: { route_id: label }
self._cms_routes: Dict[str, str] = {}
@@ -236,7 +236,6 @@ class DiscoveryProvider(StreamingProvider):
)
self.playback_manager = DiscoveryPlaybackManager(
provider=self,
channels_cache=self._channels_cache,
playback_cache=self._playback_cache,
)
@@ -408,70 +407,48 @@ class DiscoveryProvider(StreamingProvider):
def get_manifest(self, content_id: str, **kwargs) -> Optional[str]:
"""
Get manifest URL for a channel by ID.
Get manifest URL for a channel or event by ID.
Raises:
ChannelNotFoundError: If channel not in cache.
``content_id`` is the ``edit_id`` (playback identifier), so it is
passed directly to the playback manager — no cache indirection needed.
"""
try:
disco_channel = self._channels_cache.get(content_id)
if not disco_channel:
raise ChannelNotFoundError(content_id)
edit_id = disco_channel.edit_id
if not edit_id:
raise ManifestFetchError(
f"No edit_id for channel {content_id}"
)
playback_data = self.playback_manager.get_cached_playback_info(
edit_id=edit_id
edit_id=content_id
)
streaming_data = self.playback_manager.extract_streaming_data(
playback_data
)
return streaming_data.get("manifest_url")
except (ChannelNotFoundError, ManifestFetchError):
except ManifestFetchError:
raise
except Exception as e:
logger.error(
f"Error getting manifest for channel {content_id}: {e}"
f"Error getting manifest for {content_id}: {e}"
)
return None
def get_drm(self, content_id: str, **kwargs) -> List[DRMConfig]:
"""Get all DRM configurations for a channel by ID."""
"""
Get all DRM configurations for a channel or event by ID.
``content_id`` is the ``edit_id`` (playback identifier), so it is
passed directly to the playback manager — no cache indirection needed.
"""
try:
disco_channel = self._channels_cache.get(content_id)
if not disco_channel:
logger.warning(
f"Channel {content_id} not in cache for DRM"
)
return []
edit_id = disco_channel.edit_id
if not edit_id:
return []
playback_data = self.playback_manager.get_cached_playback_info(
edit_id=edit_id
edit_id=content_id
)
streaming_data = self.playback_manager.extract_streaming_data(
playback_data
)
if not streaming_data["license_url"]:
return []
drm_config = self.playback_manager.build_drm_config(
streaming_data
)
drm_config = self.playback_manager.build_drm_config(streaming_data)
return [drm_config] if drm_config else []
except Exception as e:
logger.error(
f"Error getting DRM configs for channel {content_id}: {e}"
f"Error getting DRM configs for {content_id}: {e}"
)
return []