Add discovery

This commit is contained in:
Nirvana
2026-03-03 10:57:08 +01:00
parent 335e151ddb
commit e37d79c84d
4 changed files with 1610 additions and 1432 deletions
@@ -0,0 +1,277 @@
# streaming_providers/providers/discovery/channel_manager.py
"""
Discovery+ Channel Manager
Handles CMS channel extraction, distribution channel parsing,
and CMS navigation route discovery from the /home response graph.
"""
from typing import Dict, List, Optional
from ...base.models import StreamingChannel
from ...base.utils.logger import logger
from .constants import (
CMS_INCLUDE_PARAMS,
CMS_PAGE_SIZE,
CMS_ROUTE_SPORT_SCHEDULE,
)
from .models import DiscoveryChannel
class DiscoveryChannelManager:
"""
Manages channel extraction and CMS route discovery for Discovery+.
Responsibilities:
- Fetching and parsing distribution channels from the CMS /home route
- Maintaining the ``_channels_cache`` (channel_id → DiscoveryChannel)
- Discovering and caching navigation routes from the /home response graph
into ``_cms_routes`` (route_id → label)
Both caches are owned by the provider and passed in by reference so that
the event manager and playback manager can share them without coupling
directly to this class.
"""
def __init__(
self,
provider, # DiscoveryProvider — avoid circular import with a late reference
channels_cache: Dict[str, DiscoveryChannel],
cms_routes: Dict[str, str],
):
self._provider = provider
self._channels_cache = channels_cache
self._cms_routes = cms_routes
# =========================================================================
# Public interface
# =========================================================================
def fetch_channels(
self,
fetch_manifests: bool = False,
populate_streaming_data: bool = True,
) -> List[StreamingChannel]:
"""
Fetch available channels from the Discovery+ CMS /home route.
Populates ``_channels_cache`` with DiscoveryChannel objects and
refreshes ``_cms_routes`` from the navigation graph embedded in the
same response (avoiding a second round-trip).
Args:
fetch_manifests: Whether to immediately populate streaming data.
populate_streaming_data: Whether to populate streaming data.
Returns:
List of StreamingChannel objects.
"""
try:
headers = self._provider.get_auth_headers()
url = self._provider.authenticator.cms_home_endpoint
params = {
"include": CMS_INCLUDE_PARAMS,
"decorators": "viewingHistory,isFavorite,contentAction,badges",
"page[items.size]": CMS_PAGE_SIZE,
}
response = self._provider.http_manager.get(
url,
operation="cms",
headers=headers,
params=params,
)
response.raise_for_status()
data = response.json()
# Refresh CMS route cache from this /home response.
refreshed = self._discover_cms_routes(data)
if refreshed:
self._cms_routes.clear()
self._cms_routes.update(refreshed)
logger.debug(
f"CMS routes refreshed: {list(self._cms_routes.keys())}"
)
# Extract channels and populate the shared cache
discovery_channels = self._extract_distribution_channels(data)
self._channels_cache.clear()
self._channels_cache.update(
{ch.channel_id: ch for ch in discovery_channels}
)
# Convert to StreamingChannel objects
streaming_channels = [
ch.to_streaming_channel(self._provider.provider_name)
for ch in discovery_channels
]
logger.info(
f"Found {len(streaming_channels)} distribution channels"
)
if fetch_manifests and populate_streaming_data and streaming_channels:
streaming_channels = (
self._provider.playback_manager.populate_streaming_data(
streaming_channels
)
)
return streaming_channels
except Exception as e:
logger.error(f"Error fetching channels: {e}")
return []
def init_cms_routes(self) -> None:
"""
Fetch ``/home`` and populate ``_cms_routes`` eagerly.
Called once at the end of ``DiscoveryProvider.__init__`` so route
discovery is complete before any call to ``get_events()`` or
``get_channels()``. Failures are caught and logged — the provider
remains usable and ``get_events()`` will fall back to
``CMS_ROUTE_SPORT_SCHEDULE``.
"""
try:
headers = self._provider.get_auth_headers()
url = self._provider.authenticator.cms_home_endpoint
params = {
"include": CMS_INCLUDE_PARAMS,
"decorators": "viewingHistory,isFavorite,contentAction,badges",
"page[items.size]": CMS_PAGE_SIZE,
}
response = self._provider.http_manager.get(
url,
operation="cms",
headers=headers,
params=params,
)
response.raise_for_status()
data = response.json()
routes = self._discover_cms_routes(data)
self._cms_routes.update(routes)
if routes:
logger.debug(
f"CMS routes discovered at init: {list(self._cms_routes.keys())}"
)
else:
logger.warning(
"CMS route discovery returned no routes — "
f"get_events() will fall back to '{CMS_ROUTE_SPORT_SCHEDULE}'"
)
except Exception as e:
logger.warning(f"Could not discover CMS routes at init: {e}")
# =========================================================================
# CMS route discovery
# =========================================================================
@staticmethod
def _discover_cms_routes(data: dict) -> Dict[str, str]:
"""
Parse a /home CMS response and return a map of all navigation routes.
The /home response contains a ``data.relationships.navigationLinks``
array whose entries are resolved via the top-level ``included`` list.
Each resolved node has ``attributes.url`` (e.g. ``"/sports"``) and
``attributes.label`` (localised display name).
Returns:
dict mapping route_id → label, e.g.
``{"sports": "Sport", "channels": "Kanäle", "home": "Home"}``
Returns an empty dict if the navigation graph is absent.
"""
included_by_id: Dict[str, dict] = {
f"{item['type']}:{item['id']}": item
for item in data.get("included", [])
if item.get("id") and item.get("type")
}
nav_refs = (
data.get("data", {})
.get("relationships", {})
.get("navigationLinks", {})
.get("data", [])
)
if not nav_refs:
logger.debug(
"_discover_cms_routes: no navigationLinks found in /home response"
)
return {}
routes: Dict[str, str] = {}
for ref in nav_refs:
key = f"{ref.get('type', 'link')}:{ref['id']}"
node = included_by_id.get(key, {})
attrs = node.get("attributes", {})
url_path = attrs.get("url", "").lstrip("/")
route_id = url_path or ref["id"]
label = attrs.get("label", route_id)
routes[route_id] = label
return routes
# =========================================================================
# Channel extraction helpers
# =========================================================================
def _extract_distribution_channels(
self, data: Dict
) -> List[DiscoveryChannel]:
"""
Extract distributionChannel objects from a CMS response.
Args:
data: Parsed JSON response from the CMS home route.
Returns:
List of DiscoveryChannel objects.
"""
channels = []
included_by_id: Dict[str, dict] = {}
for item in data.get("included", []):
item_id = item.get("id")
item_type = item.get("type")
if item_id and item_type:
included_by_id[f"{item_type}:{item_id}"] = item
for item in data.get("included", []):
if item.get("type") == "distributionChannel":
channel = self._create_discovery_channel_from_distribution(
item, included_by_id
)
if channel:
channels.append(channel)
return channels
@staticmethod
def _create_discovery_channel_from_distribution(
distribution_data: Dict,
included_by_id: Dict,
) -> Optional[DiscoveryChannel]:
"""
Create a DiscoveryChannel from a distributionChannel CMS object.
Args:
distribution_data: The distributionChannel item from the CMS.
included_by_id: Lookup dict of all included items (``type:id`` → item).
Returns:
DiscoveryChannel instance, or None if the data is invalid.
"""
try:
discovery_channel = DiscoveryChannel.from_distribution_data(
distribution_data, included_by_id
)
logger.debug(
f"Created channel: {discovery_channel.name} "
f"(edit_id: {discovery_channel.edit_id})"
)
return discovery_channel
except Exception as e:
logger.error(
f"Error creating channel from distribution data: {e}"
)
return None
@@ -0,0 +1,757 @@
# streaming_providers/providers/discovery/event_manager.py
"""
Discovery+ Event Manager
Handles sport schedule and live event fetching from the Discovery+ CMS.
Supports two parse paths:
1. /sports — airing items directly in ``included`` (fast path)
2. /sport-schedule — items nested inside a collection (legacy fallback)
"""
from datetime import datetime, timezone
from typing import ClassVar, Dict, List, Optional
from ...base.models import Event, EventStatus
from ...base.utils.logger import logger
from .constants import (
AIRING_BADGE_LIVE,
AIRING_BADGE_UP_NEXT,
AIRING_BADGE_UPCOMING_LEGACY,
AIRING_ITEM_TYPE,
CMS_INCLUDE_PARAMS,
CMS_ROUTE_SPORT_SCHEDULE,
CMS_ROUTE_SPORTS,
)
class DiscoveryEventManager:
"""
Manages event (sport schedule) fetching for Discovery+.
Responsibilities:
- Dynamic CMS route selection from the cached ``_cms_routes`` map
- Airing-based event parsing (``/sports`` route — fast path)
- Collection-based event traversal (``/sport-schedule`` — legacy fallback)
- Taxonomy relationship resolution (genre, competition, venue, …)
The shared ``_channels_cache`` and ``_cms_routes`` dicts are owned by the
provider and passed in so the manager can look up channel names and pick
the right route without coupling to the provider itself.
"""
# Route preference order — first matching discovered route wins.
# ``sport-schedule`` (paginated full schedule, ~300 events) is preferred
# over ``sports`` (featured/current airings only, ~14 events).
_EVENT_ROUTE_PREFERENCE: ClassVar[tuple] = (
CMS_ROUTE_SPORT_SCHEDULE, # "sport-schedule" — full paginated schedule
CMS_ROUTE_SPORTS, # "sports" — featured airings only (fallback)
)
# Generic keyword scan used when neither preferred route is discovered
_EVENT_ROUTE_KEYWORDS: ClassVar[tuple] = ("schedule", "sport", "live", "event")
def __init__(self, provider, channels_cache: Dict, cms_routes: Dict):
self._provider = provider
self._channels_cache = channels_cache
self._cms_routes = cms_routes
# =========================================================================
# Public interface
# =========================================================================
def fetch_events(
self,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
**kwargs,
) -> List[Event]:
"""
Fetch events from the Discovery+ schedule.
**Route selection** (no ``route_id`` kwarg supplied):
Calls ``_select_event_route()`` which prefers a route discovered
dynamically from the ``/home`` navigation graph (populated by
``get_channels()``). Falls back to ``CMS_ROUTE_SPORT_SCHEDULE``
if no suitable route is found.
**Parse paths** (determined by the selected route's response shape):
1. ``/sports`` — ``airing`` items appear directly in ``included``
(fast path, ``_parse_airing_events()``).
2. ``/sport-schedule`` — items are nested inside a collection
(legacy fallback, ``_fetch_events_via_collection()``).
The fast path is attempted first; if no ``airing`` items are found
the method retries with ``CMS_ROUTE_SPORT_SCHEDULE``.
Args:
start_time: Optional start time filter.
end_time: Optional end time filter.
**kwargs: Additional parameters including:
- route_id: Override the CMS route (bypasses dynamic selection).
- page_size: Number of items per page (collection path only).
Returns:
List of Event objects.
"""
events = []
try:
headers = self._provider.get_auth_headers()
route_id = kwargs.get("route_id") or self._select_event_route()
# Build the CMS route URL
base_url = self._provider.authenticator.cms_home_endpoint
if base_url.endswith("/home"):
url = base_url.replace("/home", f"/{route_id}")
else:
url = (
f"https://default.any-{self._provider.authenticator.home_market}"
f".{self._provider.authenticator.env}.api.discoveryplus.com"
f"/cms/routes/{route_id}"
)
params = {
"include": CMS_INCLUDE_PARAMS,
"decorators": "viewingHistory,isFavorite,contentAction,badges",
"page[items.size]": kwargs.get("page_size", 10),
}
if start_time:
params["filter[from]"] = start_time.isoformat()
if end_time:
params["filter[to]"] = end_time.isoformat()
logger.debug(f"Fetching events route: {url}")
response = self._provider.http_manager.get(
url,
operation="cms",
headers=headers,
params=params,
)
response.raise_for_status()
data = response.json()
# ------------------------------------------------------------------
# Fast path: /sports route returns airing items directly in included
# ------------------------------------------------------------------
airing_items = [
item for item in data.get("included", [])
if item.get("type") == AIRING_ITEM_TYPE
]
if airing_items:
logger.debug(
f"Found {len(airing_items)} airing items in /sports route — "
"using direct airing parse path"
)
events = self._parse_airing_events(
airing_items, start_time, end_time
)
logger.info(f"Found {len(events)} events (airing path)")
return events
# ------------------------------------------------------------------
# Slow path: collection-based traversal (legacy /sport-schedule shape)
# ------------------------------------------------------------------
if route_id == CMS_ROUTE_SPORTS:
logger.debug(
"No airing items on /sports route — falling back to "
f"/{CMS_ROUTE_SPORT_SCHEDULE} collection path"
)
return self.fetch_events(
start_time=start_time,
end_time=end_time,
route_id=CMS_ROUTE_SPORT_SCHEDULE,
**{k: v for k, v in kwargs.items() if k != "route_id"},
)
events = self._fetch_events_via_collection(
data=data,
headers=headers,
start_time=start_time,
end_time=end_time,
**kwargs,
)
logger.info(f"Found {len(events)} events (collection path)")
except Exception as e:
logger.error(f"Error fetching events: {e}")
return events
# =========================================================================
# Route selection
# =========================================================================
def _select_event_route(self) -> str:
"""
Return the best event-producing CMS route from ``_cms_routes``.
Preference order:
1. ``CMS_ROUTE_SPORT_SCHEDULE`` — full paginated schedule (~300 events).
2. ``CMS_ROUTE_SPORTS`` — featured/current airings only (~14).
3. First discovered route matching a keyword in
``_EVENT_ROUTE_KEYWORDS`` (generic fallback for unknown layouts).
4. ``CMS_ROUTE_SPORT_SCHEDULE`` hard-coded constant when
``_cms_routes`` is empty (e.g. ``get_channels()`` hasn't been
called yet).
"""
for preferred in self._EVENT_ROUTE_PREFERENCE:
if preferred in self._cms_routes:
logger.debug(
f"_select_event_route: selected preferred route '{preferred}'"
)
return preferred
for keyword in self._EVENT_ROUTE_KEYWORDS:
match = next(
(r for r in self._cms_routes if keyword in r.lower()), None
)
if match:
logger.debug(
f"_select_event_route: selected '{match}' via keyword '{keyword}'"
)
return match
logger.debug(
f"_select_event_route: no event route found in "
f"{list(self._cms_routes.keys())!r}, "
f"falling back to '{CMS_ROUTE_SPORT_SCHEDULE}'"
)
return CMS_ROUTE_SPORT_SCHEDULE
# =========================================================================
# Airing-based event parsing (/sports route — fast path)
# =========================================================================
def _parse_airing_events(
self,
airing_items: List[Dict],
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
) -> List[Event]:
"""
Parse a list of ``airing`` objects from the /sports CMS route into
``Event`` instances.
The /sports route places ``airing`` items directly in the top-level
``included`` array. Each airing carries:
- ``attributes.scheduleStart / scheduleEnd`` — ISO-8601 UTC timestamps
- ``attributes.name`` — episode/event title
- ``attributes.showName`` — parent show name
- ``attributes.description / secondaryTitle`` — optional metadata
- ``attributes.episodeNumber / seasonNumber`` — episode metadata
- ``relationships.badges`` — live / up-next status
- ``relationships.distributionChannel`` — links to channel cache
Badge → EventStatus mapping:
AIRING_BADGE_LIVE ("live") → EventStatus.LIVE
AIRING_BADGE_UP_NEXT ("release-state-up-next") → EventStatus.UPCOMING
(fallback clock-based logic handles ENDED / LIVE for unlabelled items)
Args:
airing_items: List of airing dicts from ``data["included"]``.
start_time: Optional lower bound filter (inclusive).
end_time: Optional upper bound filter (inclusive).
Returns:
List of Event objects, optionally filtered by time window.
"""
events: List[Event] = []
now_utc = datetime.now(timezone.utc)
for airing in airing_items:
try:
attributes = airing.get("attributes", {})
relationships = airing.get("relationships", {})
# ---- schedule times -----------------------------------------
start_dt, end_dt = self._parse_schedule_times(attributes)
# ---- optional caller-supplied time window filter -------------
if start_time and end_dt and end_dt < start_time:
continue
if end_time and start_dt and start_dt > end_time:
continue
# ---- event status from badges --------------------------------
status = self._resolve_badge_status(
relationships, now_utc, start_dt, end_dt
)
# ---- channel lookup -----------------------------------------
channel_name: 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
)
if dist_channel_id:
cached = self._channels_cache.get(dist_channel_id)
if cached:
channel_name = cached.name
else:
logger.debug(
f"distributionChannel {dist_channel_id} not in cache "
f"for airing '{attributes.get('name')}'"
)
# ---- build Event --------------------------------------------
event = Event(
name=attributes.get("name", "Unknown Event"),
content_id=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,
content_type="AIRING",
description=attributes.get("description", ""),
genre=None,
language="de",
country=self._provider.country.upper(),
start_time=start_dt,
end_time=end_dt,
status=status,
subtitle=attributes.get("secondaryTitle"),
original_name=attributes.get("showName"),
competition=None,
venue=None,
gender=None,
discipline=None,
age_category=None,
master_event=None,
channel=channel_name,
)
events.append(event)
except Exception as e:
logger.error(
f"Error processing airing item {airing.get('id')}: {e}"
)
continue
return events
# =========================================================================
# Legacy collection-based event fetching (/sport-schedule route)
# =========================================================================
def _fetch_events_via_collection(
self,
data: Dict,
headers: Dict,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
**kwargs,
) -> List[Event]:
"""
Traverse the legacy collection-based CMS shape to extract events.
This implements the original multi-hop traversal:
route data → target page → page items → collection →
paginated collection items → video attributes
Args:
data: Parsed JSON from the initial CMS route request.
headers: Auth headers for subsequent requests.
start_time: Optional lower bound filter.
end_time: Optional upper bound filter.
**kwargs: Forwarded kwargs (page_size, etc.).
Returns:
List of Event objects.
"""
events: List[Event] = []
included_by_id: Dict[str, Dict] = {
f"{item.get('type')}:{item.get('id')}": item
for item in data.get("included", [])
if item.get("id") and item.get("type")
}
# Find the target page from the route
route_data = data.get("data", {})
target_ref = (
route_data.get("relationships", {})
.get("target", {})
.get("data")
)
if not target_ref:
logger.error(
"No target page found in route response (collection path)"
)
return events
page_key = f"{target_ref.get('type')}:{target_ref.get('id')}"
page = included_by_id.get(page_key)
if not page:
logger.error(
"Target page not found in included items (collection path)"
)
return events
# Find the collection from page items
page_items = (
page.get("relationships", {}).get("items", {}).get("data", [])
)
collection_id = None
for page_item_ref in page_items:
page_item_key = (
f"{page_item_ref.get('type')}:{page_item_ref.get('id')}"
)
page_item = included_by_id.get(page_item_key)
if page_item:
collection_ref = (
page_item.get("relationships", {})
.get("collection", {})
.get("data")
)
if collection_ref:
collection_id = collection_ref.get("id")
break
if not collection_id:
logger.error("No collection found in page (collection path)")
return events
# Paginate through the collection
collection_url = (
f"{self._provider.authenticator.cms_collections_endpoint}"
f"/{collection_id}"
)
current_page = 1
while True:
collection_params = {
"include": "default",
"decorators": "viewingHistory,isFavorite,contentAction,badges",
"page[items.size]": kwargs.get("page_size", 30),
"page[items.number]": current_page,
}
logger.debug(
f"Fetching schedule collection page {current_page}: "
f"{collection_url}"
)
collection_response = self._provider.http_manager.get(
collection_url,
operation="cms",
headers=headers,
params=collection_params,
)
collection_response.raise_for_status()
collection_data = collection_response.json()
page_included: Dict[str, Dict] = {
f"{item.get('type')}:{item.get('id')}": item
for item in collection_data.get("included", [])
if item.get("id") and item.get("type")
}
items = (
collection_data.get("data", {})
.get("relationships", {})
.get("items", {})
.get("data", [])
)
meta = collection_data.get("meta", {})
total_pages = meta.get("itemsTotalPages", 1)
logger.debug(
f"Fetched collection page {current_page}/{total_pages} "
f"({len(items)} items)"
)
for item_ref in items:
try:
item_key = (
f"{item_ref.get('type')}:{item_ref.get('id')}"
)
collection_item = page_included.get(item_key)
if not collection_item:
continue
video_ref = (
collection_item.get("relationships", {})
.get("video", {})
.get("data")
)
if not video_ref:
continue
video_key = (
f"{video_ref.get('type')}:{video_ref.get('id')}"
)
video_data = page_included.get(video_key)
if not video_data:
continue
attributes = video_data.get("attributes", {})
relationships = video_data.get("relationships", {})
if attributes.get("materialType") != "EVENT":
continue
# ---- status from badges ----------------------------------
now_utc = datetime.now(timezone.utc)
status = EventStatus.SCHEDULED
badge_refs = relationships.get("badges", {}).get("data", [])
badge_ids = {b.get("id") for b in badge_refs}
if AIRING_BADGE_LIVE in badge_ids:
status = EventStatus.LIVE
elif (
AIRING_BADGE_UP_NEXT in badge_ids
or AIRING_BADGE_UPCOMING_LEGACY in badge_ids
):
status = EventStatus.SCHEDULED
for badge_ref in badge_refs:
overlay_key = (
f"{badge_ref.get('type')}:{badge_ref.get('id')}"
)
overlay = page_included.get(overlay_key, {})
if overlay.get("id") == "live:default":
status = EventStatus.LIVE
break
# ---- schedule times -------------------------------------
start_dt, end_dt = self._parse_schedule_times(attributes)
# Clock-based status refinement
if status != EventStatus.LIVE and start_dt and end_dt:
_start = (
start_dt if start_dt.tzinfo
else start_dt.replace(tzinfo=timezone.utc)
)
_end = (
end_dt if end_dt.tzinfo
else end_dt.replace(tzinfo=timezone.utc)
)
if now_utc > _end:
status = EventStatus.ENDED
elif _start <= now_utc <= _end:
status = EventStatus.LIVE
# ---- logo -----------------------------------------------
logo_url = None
image_refs = relationships.get("images", {}).get("data", [])
if image_refs:
image_key = (
f"{image_refs[0].get('type')}:{image_refs[0].get('id')}"
)
image = page_included.get(image_key, {})
img_attrs = image.get("attributes", {})
logo_url = img_attrs.get("src") or img_attrs.get("url")
# ---- edit ID for streaming --------------------------------
edit_ref = (
relationships.get("edit", {}).get("data", {})
)
edit_id = edit_ref.get("id") if edit_ref else None
# ---- taxonomy lookups -----------------------------------
genre = self._resolve_taxonomy(
relationships, "txSports", page_included
)
competition = self._resolve_taxonomy(
relationships, "txCompetition", page_included
)
venue = self._resolve_taxonomy(
relationships, "txEvent", page_included
)
gender_val = self._resolve_taxonomy(
relationships, "txGender", page_included
)
gender = (
gender_val
if gender_val and gender_val != "."
else None
)
discipline_val = self._resolve_taxonomy(
relationships, "txDiscipline", page_included
)
discipline = (
discipline_val
if discipline_val and discipline_val != "."
else None
)
age_val = self._resolve_taxonomy(
relationships, "txAge", page_included
)
age_category = (
age_val if age_val and age_val != "." else None
)
master_event = self._resolve_taxonomy(
relationships, "txMaster-sporting-event", page_included
)
# ---- primary channel ------------------------------------
channel_name = None
channel_ref = (
relationships.get("primaryChannel", {}).get("data")
)
if channel_ref:
channel_key = (
f"{channel_ref.get('type')}:{channel_ref.get('id')}"
)
channel_node = page_included.get(channel_key, {})
channel_name = (
channel_node.get("attributes", {}).get("name")
)
# ---- language from audio tracks -------------------------
audio_tracks = attributes.get("audioTracks", [])
language = "de"
if audio_tracks:
if "Deutsch" in audio_tracks:
language = "de"
elif "Englisch" in audio_tracks:
language = "en"
event = Event(
name=attributes.get("name", "Unknown Event"),
content_id=video_data.get("id", ""),
provider=self._provider.provider_name,
logo_url=logo_url,
mode=(
"live"
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,
content_type="EVENT",
description=attributes.get("description", ""),
genre=genre,
language=language,
country=self._provider.country.upper(),
start_time=start_dt,
end_time=end_dt,
status=status,
subtitle=attributes.get("secondaryTitle"),
original_name=attributes.get("originalName"),
competition=competition,
venue=venue,
gender=gender,
discipline=discipline,
age_category=age_category,
master_event=master_event,
channel=channel_name,
)
events.append(event)
except Exception as e:
logger.error(f"Error processing collection item: {e}")
continue
if current_page >= total_pages:
break
current_page += 1
return events
# =========================================================================
# Shared parsing helpers
# =========================================================================
@staticmethod
def _parse_schedule_times(
attributes: Dict,
):
"""
Extract and parse scheduleStart / scheduleEnd from an attributes dict.
Returns:
Tuple of (start_dt, end_dt), either of which may be None.
"""
start_dt: Optional[datetime] = None
end_dt: Optional[datetime] = None
for attr_key, target in (
("scheduleStart", "start_dt"),
("scheduleEnd", "end_dt"),
):
raw = attributes.get(attr_key)
if raw:
try:
parsed = datetime.fromisoformat(
raw.replace("Z", "+00:00")
)
if target == "start_dt":
start_dt = parsed
else:
end_dt = parsed
except (ValueError, TypeError):
pass
return start_dt, end_dt
@staticmethod
def _resolve_badge_status(
relationships: Dict,
now_utc: datetime,
start_dt: Optional[datetime],
end_dt: Optional[datetime],
) -> EventStatus:
"""
Derive EventStatus from badge relationships, falling back to clock logic.
Badge priority: explicit "live" wins over "up-next"; clock-based logic
is applied when neither badge is present.
"""
badge_refs = relationships.get("badges", {}).get("data", [])
badge_ids = {b.get("id") for b in badge_refs}
if AIRING_BADGE_LIVE in badge_ids:
return EventStatus.LIVE
if (
AIRING_BADGE_UP_NEXT in badge_ids
or AIRING_BADGE_UPCOMING_LEGACY in badge_ids
):
return EventStatus.SCHEDULED
if start_dt and end_dt:
_start = (
start_dt if start_dt.tzinfo
else start_dt.replace(tzinfo=timezone.utc)
)
_end = (
end_dt if end_dt.tzinfo
else end_dt.replace(tzinfo=timezone.utc)
)
if now_utc > _end:
return EventStatus.ENDED
if _start <= now_utc <= _end:
return EventStatus.LIVE
return EventStatus.SCHEDULED
@staticmethod
def _resolve_taxonomy(
relationships: Dict,
key: str,
included: Dict,
) -> Optional[str]:
"""
Resolve a single taxonomy relationship to its ``attributes.name`` value.
Args:
relationships: The ``relationships`` dict from a video/event item.
key: Relationship key, e.g. ``"txSports"``, ``"txCompetition"``.
included: Per-page included lookup (``type:id`` → item).
Returns:
The ``attributes.name`` string, or ``None`` if not found.
"""
refs = relationships.get(key, {}).get("data", [])
if not refs:
return None
ref = refs[0]
node = included.get(f"{ref.get('type')}:{ref.get('id')}", {})
return node.get("attributes", {}).get("name")
@@ -0,0 +1,453 @@
# streaming_providers/providers/discovery/playback_manager.py
"""
Discovery+ Playback Manager
Handles all streaming/playback concerns:
- Fetching and caching playback info (tokens valid for 24 hours)
- Extracting manifest URLs and DRM details from playback responses
- Building DRMConfig objects
- Populating StreamingChannel objects with streaming data
"""
import base64
import json
import secrets
import time
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from ...base.models import DRMConfig, DRMSystem, LicenseConfig, StreamingChannel
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:
"""
Manages playback info fetching and streaming data population for Discovery+.
Responsibilities:
- Building the playback request payload (device capabilities, DRM, ads)
- Fetching playback info from the Discovery+ playback endpoint
- Caching playback tokens keyed on ``edit_id`` until DRM expiration
- Extracting manifest URLs and DRM scheme details from playback responses
- Building DRMConfig objects for Widevine / PlayReady
- Populating a list of StreamingChannel objects (``populate_streaming_data``)
The shared ``_channels_cache`` dict is owned by the provider and passed in
so the manager can resolve ``edit_id`` values without extra API calls.
"""
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
# =========================================================================
# Public interface
# =========================================================================
def populate_streaming_data(
self,
channels: List[StreamingChannel],
max_retries: int = 3,
) -> List[StreamingChannel]:
"""
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.
Args:
channels: StreamingChannel objects to populate.
max_retries: Maximum retry attempts per channel.
Returns:
List of successfully populated channels.
"""
successful_channels = []
for channel in channels:
retries = 0
success = False
is_restricted = False
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
if not edit_id:
logger.warning(
f"No edit_id for channel {channel.name}, skipping"
)
break
logger.debug(
f"Getting playback info for: {channel.name} "
f"(edit_id: {edit_id}, attempt {retries + 1})"
)
playback_data = self.get_cached_playback_info(
edit_id=edit_id
)
streaming_data = self.extract_streaming_data(playback_data)
if streaming_data["manifest_url"]:
channel.manifest = streaming_data["manifest_url"]
channel.streaming_format = streaming_data[
"streaming_format"
]
if streaming_data["license_url"]:
drm_config = self.build_drm_config(streaming_data)
if drm_config:
channel.drm_config = drm_config
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}"
)
successful_channels.append(channel)
success = True
else:
raise ManifestFetchError("No manifest URL in response")
except PlaybackRestrictedException as e:
logger.warning(
f"Playback restricted for {channel.name}: {e}"
)
is_restricted = True
except Exception as e:
retries += 1
if retries < max_retries:
logger.debug(
f"Retry {retries}/{max_retries} for "
f"{channel.name}: {e}"
)
time.sleep(1)
else:
logger.error(
f"Failed to get streaming data for "
f"{channel.name}: {e}"
)
logger.info(
f"Streaming data population complete: "
f"{len(successful_channels)}/{len(channels)}"
)
return successful_channels
def get_playback_info(self, edit_id: str, **kwargs) -> Dict:
"""
Fetch playback information for the given edit_id.
Args:
edit_id: Content edit ID.
**kwargs: Additional parameters (unused, reserved for future use).
Returns:
Playback info dictionary from the Discovery+ playback endpoint.
Raises:
ValueError: If edit_id is not provided.
PlaybackRestrictedException: If the server returns PLAYBACK_RESTRICTED.
ManifestFetchError: On any other HTTP / parse error.
"""
if not edit_id:
raise ValueError("edit_id is required for playback")
headers = self._provider.get_auth_headers()
payload = self._build_playback_payload(edit_id)
try:
response = self._provider.http_manager.post(
self._provider.authenticator.playback_endpoint,
operation="playback",
headers=headers,
json=payload,
timeout=30,
)
if response.status_code >= 400:
error_text = response.text
logger.error(f"Playback error response: {error_text}")
if "PLAYBACK_RESTRICTED" in error_text:
raise PlaybackRestrictedException(
reason=f"Playback restricted for {edit_id}",
error_code="PLAYBACK_RESTRICTED",
)
response.raise_for_status()
response.raise_for_status()
return response.json()
except PlaybackRestrictedException:
raise
except Exception as e:
raise ManifestFetchError(
f"Error getting playback info for edit_id {edit_id}: {e}"
)
# =========================================================================
# Playback cache
# =========================================================================
def get_cached_playback_info(self, edit_id: str) -> Dict:
"""
Return playback info for edit_id, using a time-based cache.
Tokens issued by Discovery+ are valid for 24 hours. The cache entry
is kept until the ``drm_expiration`` timestamp returned in the response
(minus a 60-second safety margin). If no expiration is present a
fallback TTL of 23 hours is used.
Args:
edit_id: Content edit ID.
Returns:
Playback info dictionary (from cache or freshly fetched).
"""
now = time.time()
cached = self._playback_cache.get(edit_id)
if cached:
expiry, data = cached
if now < expiry:
logger.debug(
f"Playback cache hit for edit_id {edit_id} "
f"(expires in {int(expiry - now)}s)"
)
return data
else:
logger.debug(
f"Playback cache expired for edit_id {edit_id}, re-fetching"
)
data = self.get_playback_info(edit_id=edit_id)
expiry = None
try:
drm_expiration_str = data.get("drm", {}).get("expirationDate")
if drm_expiration_str:
dt = datetime.fromisoformat(
drm_expiration_str.replace("Z", "+00:00")
)
expiry = dt.timestamp() - 60
except Exception as e:
logger.debug(f"Could not parse drm_expiration: {e}")
if expiry is None or expiry <= now:
expiry = now + 23 * 3600
logger.debug(
f"Using fallback 23h TTL for playback cache "
f"(edit_id: {edit_id})"
)
self._playback_cache[edit_id] = (expiry, data)
logger.debug(
f"Playback cache stored for edit_id {edit_id} "
f"(expires in {int(expiry - now)}s)"
)
return data
# =========================================================================
# Payload / streaming data helpers
# =========================================================================
def _build_playback_payload(self, edit_id: str) -> Dict[str, Any]:
"""
Build the playback request payload for the Discovery+ playback API.
Args:
edit_id: Content edit ID.
Returns:
JSON-serialisable payload dict.
"""
nonce = secrets.token_bytes(256)
google_pal_nonce = base64.b64encode(nonce).decode("ascii")
return {
"appBundle": "dplus",
"advertisingInfo": {
"adBlockerDetection": False,
"debug": {},
"device": {},
"googlePALNonce": google_pal_nonce,
"server": {
"deviceId": "",
"iabTCFString": "",
"isLimitedAdTracking": 0,
"nielsenAppId": "",
},
"ssaiProvider": {"version": "2.2.0"},
},
"consumptionType": "streaming",
"deviceInfo": get_default_device_info(
self._provider.platform_os,
device_id=self._provider.authenticator.device_id,
),
"editId": edit_id,
"capabilities": get_default_capabilities(
self._provider.platform_os
),
"gdpr": False,
"firstPlay": False,
"playbackSessionId": str(uuid.uuid4()),
"applicationSessionId": str(uuid.uuid4()),
"userPreferences": {
"videoQuality": "best",
"uiLanguage": f"{self._provider.country}-DE".upper(),
},
"features": ["mlp"],
}
@staticmethod
def extract_streaming_data(playback_data: Dict) -> Dict[str, Any]:
"""
Extract streaming URLs and DRM info from a playback response.
Detects whichever DRM scheme is present — either ``widevine``
(Linux/Chrome) or ``playready`` (Windows/Edge) — so callers can build
the correct DRMConfig without knowing which platform produced the
response.
Args:
playback_data: Playback API response dict.
Returns:
Dictionary with keys:
manifest_url DASH manifest URL
license_url DRM license server URL (or None)
drm_system 'widevine' | 'playready' | None
drm_auth JWT auth token from license URL (or None)
streaming_format 'dash' (always for Discovery+)
drm_expiration ISO-8601 expiration string (or None)
fallback_manifest Fallback manifest URL (or None)
"""
result: Dict[str, Any] = {
"manifest_url": None,
"license_url": None,
"drm_system": None,
"drm_auth": None,
"streaming_format": "dash",
"drm_expiration": None,
"fallback_manifest": None,
}
try:
manifest = playback_data.get("manifest", {})
if manifest:
result["manifest_url"] = manifest.get("url")
result["streaming_format"] = manifest.get("format", "dash")
fallback = playback_data.get("fallback", {})
if fallback:
fallback_manifest = fallback.get("manifest", {})
if fallback_manifest:
result["fallback_manifest"] = fallback_manifest.get("url")
drm = playback_data.get("drm", {})
if drm:
result["drm_expiration"] = drm.get("expirationDate")
schemes = drm.get("schemes", {})
for scheme_name in ("widevine", "playready"):
scheme = schemes.get(scheme_name, {})
if scheme and scheme.get("licenseUrl"):
result["license_url"] = scheme["licenseUrl"]
result["drm_system"] = scheme_name
if "auth=" in result["license_url"]:
import urllib.parse
parsed = urllib.parse.urlparse(
result["license_url"]
)
query = urllib.parse.parse_qs(parsed.query)
if "auth" in query:
result["drm_auth"] = query["auth"][0]
break
cdn = playback_data.get("cdn", {})
if cdn:
result["cdn_provider"] = cdn.get("provider")
except Exception as e:
logger.error(f"Error extracting streaming data: {e}")
return result
def build_drm_config(
self, streaming_data: Dict[str, Any]
) -> Optional[DRMConfig]:
"""
Build a DRMConfig from extracted streaming data.
Selects the correct DRMSystem based on the scheme returned by the
server (``streaming_data["drm_system"]``). Falls back to the active
``platform_os`` when the scheme is absent.
Supported schemes:
'widevine' → DRMSystem.WIDEVINE (Linux/Chrome path)
'playready' → DRMSystem.PLAYREADY (Windows/Edge path)
Args:
streaming_data: Dict returned by ``extract_streaming_data()``.
Returns:
DRMConfig instance, or None if no license URL is available.
"""
license_url = streaming_data.get("license_url")
if not license_url:
return None
drm_system_str = streaming_data.get("drm_system")
if drm_system_str == "playready":
drm_system = DRMSystem.PLAYREADY
elif drm_system_str == "widevine":
drm_system = DRMSystem.WIDEVINE
else:
drm_system = (
DRMSystem.PLAYREADY
if self._provider.platform_os == PlatformOS.WINDOWS
else DRMSystem.WIDEVINE
)
logger.debug(
f"drm_system not in streaming_data, inferred from platform_os "
f"({self._provider.platform_os.value}): {drm_system.name}"
)
license_headers = get_drm_request_headers(self._provider.platform_os)
return DRMConfig(
system=drm_system,
priority=1,
license=LicenseConfig(
server_url=license_url,
req_headers=json.dumps(license_headers),
req_data="{CHA-RAW}",
use_http_get_request=False,
),
)
File diff suppressed because it is too large Load Diff