Add discovery

This commit is contained in:
Nirvana
2026-03-18 19:14:50 +01:00
parent c81dec6d5b
commit fa9c29f95e
9 changed files with 240 additions and 344 deletions
+5 -18
View File
@@ -650,28 +650,15 @@ class StreamingProvider(ABC):
"""
return False
def get_vod_category(
self,
category_path: List[str],
fetch_url: Optional[str] = None,
**kwargs
) -> List:
def get_vod_category(self, content_id: str = "", **kwargs) -> List:
"""
Return the children of a VOD tree node.
Args:
category_path: Ordered list of content_ids from root to the node
whose children are requested, e.g.:
[] -> root
["sports_id"] -> top-level sports node
["sports_id", "golf_id"] -> golf sub-node
The provider typically only needs category_path[-1]
(the immediate parent id), but the full path is
provided for providers that require ancestor context.
fetch_url: Full URL (including portal-scoping query params such
as ?whiteLabelId=megathek) to use for the fetch.
Populated from VodCategory.fetch_url by the caller so
that query params survive HTTP router path splitting.
content_id: Opaque node identifier returned by a previous
get_vod_category call. Empty string → root level.
Providers define their own ID format; the caller
treats it as an opaque token and never parses it.
Returns:
Mixed list of VodCategory and VodItem objects.
+9 -66
View File
@@ -6,7 +6,7 @@ Follows the same pattern as ChannelOperations and EventOperations.
from typing import Dict, List, Optional, Union
from .models.vod import VodCategory, VodItem, build_slug_map
from .models.vod import VodCategory, VodItem
from .utils.logger import logger
@@ -27,41 +27,6 @@ class VodOperations:
raise ValueError(f"Provider '{provider_name}' not found or disabled")
return provider
def _resolve_path_to_ids(
self, provider_name: str, slug_segments: List[str]
) -> List[str]:
"""
Walk the VOD tree segment by segment, converting URL slugs to
content_ids.
Strategy per segment:
1. Ask the provider for the children of the current path (using
already-resolved IDs up to this point).
2. Build a slug → id map for those children.
3. Look up the next slug in that map.
4. If found, append the resolved id and continue.
5. If not found, raise ValueError (→ 404).
Returns the fully resolved list of content_ids.
"""
provider = self._get_provider(provider_name)
resolved_ids: List[str] = []
for slug in slug_segments:
children = provider.get_vod_category(resolved_ids)
slug_map = build_slug_map(children)
if slug not in slug_map:
raise ValueError(
f"VOD path segment '{slug}' not found under "
f"'{'/'.join(resolved_ids) or 'root'}' "
f"for provider '{provider_name}'"
)
resolved_ids.append(slug_map[slug])
return resolved_ids
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@@ -69,51 +34,29 @@ class VodOperations:
def get_vod_node(
self,
provider_name: str,
slug_segments: List[str],
content_id: str = "",
**kwargs,
) -> List[Union[VodCategory, VodItem]]:
"""
Resolve a slug path and return the children of that node.
Return the children of a VOD node.
Args:
provider_name: Provider to query.
slug_segments: URL path segments as slugs, e.g.
["sports", "golf", "pga"].
Empty list → root level.
content_id: Opaque node identifier from a previous response
(VodCategory.content_id). Empty string → root.
Returns:
Mixed list of VodCategory and VodItem entries.
Raises:
ValueError: Provider not found, or any path segment does not
resolve to a known child (→ 404).
ValueError: Provider not found.
"""
provider = self._get_provider(provider_name)
if not slug_segments:
# Root — no resolution needed
children = provider.get_vod_category([])
logger.info(
f"Retrieved {len(children)} root VOD entries from '{provider_name}'"
)
return children
# Pass slug segments directly to the provider.
# Providers whose content_ids are full route paths (e.g. Discovery+)
# will join the segments themselves into the correct CMS route.
# The old slug-walking approach (_resolve_path_to_ids) is bypassed
# because it fetches every intermediate level unnecessarily and fails
# when the provider's tree is too deep or slugs don't match exactly.
#
# fetch_url: passed through from the VodCategory returned at the
# previous level so that portal-scoping query params (e.g.
# ?whiteLabelId=megathek) survive the HTTP router's path splitting.
fetch_url = kwargs.pop("fetch_url", None)
children = provider.get_vod_category(slug_segments, fetch_url=fetch_url)
children = provider.get_vod_category(content_id=content_id, **kwargs)
label = content_id or "root"
logger.info(
f"Retrieved {len(children)} VOD entries from '{provider_name}' "
f"at path '{'/'.join(slug_segments)}'"
f"at '{label}'"
)
return children
@@ -401,20 +401,13 @@ class DiscoveryProvider(StreamingProvider):
**kwargs,
)
def get_vod_category(self, category_path: List[str], **kwargs) -> List:
def get_vod_category(self, content_id: str = "", **kwargs) -> List:
"""
Return VOD children for path (list of path segments or content_ids):
[] -> root (4 buckets)
["sports"] or ["/sports"] -> sport subcategories
["sports","nordic-combined"]
or ["/sports/nordic-combined"]-> VodItems / sub-categories
["genre","true-crime"] -> VodCategories (shows)
["/show/{uuid}"] -> VodItems (episodes)
Return VOD children for the given content_id (opaque CMS route path).
Empty string → root level.
"""
return self.vod_manager.get_vod_category(content_id=content_id)
Both raw URL-segment lists and content_id lists (starting with "/")
are accepted — DiscoveryVodManager normalises them to a CMS route.
"""
return self.vod_manager.get_vod_category(category_path)
def populate_streaming_data(
self,
@@ -81,44 +81,20 @@ class DiscoveryVodManager:
# ------------------------------------------------------------------
def get_vod_category(
self, category_path: List[str], **kwargs
self, content_id: str = "", **kwargs
) -> List[Union[VodCategory, VodItem]]:
"""
Return the children of the VOD node identified by *path*.
Return the children of the VOD node identified by *content_id*.
Handles two calling conventions:
1. content_id convention (internal / API-aware callers):
Each element is already a full CMS route path as returned by a
previous call, e.g. ["/sports"], ["/sports/nordic-combined"].
Only the last element is used — it is the complete route.
2. URL-segment convention (base VodOperations path-resolver):
The base layer walks the tree level-by-level using slugified names
and passes raw path segments, e.g. ["sports", "nordic-combined"].
We detect this and reconstruct the CMS route by joining all segments.
Both conventions produce the same CMS route, so every depth level
(root → sport-group → sub-group → video) works identically.
content_id is a single opaque token — the CMS route path returned by
a previous call (e.g. "/sports", "/sports/nordic-combined").
Empty string → root.
"""
if not category_path:
if not content_id:
return self._root()
last = category_path[-1]
if last.startswith("/"):
# Convention 1: last element is already a full CMS route path.
# This is the normal path when content_ids flow through correctly.
route = last
else:
# Convention 2: raw URL segments from the base path-resolver.
# Join all segments to reconstruct the full CMS route.
# e.g. ["sports", "nordic-combined"] → "/sports/nordic-combined"
# e.g. ["genre", "true-crime", "show-slug"] → "/genre/true-crime/show-slug"
route = "/" + "/".join(s.strip("/") for s in category_path)
logger.debug(f"DiscoveryVodManager: get_vod_category({category_path!r}) → route '{route}'")
return self._fetch_children(route)
logger.debug(f"DiscoveryVodManager: get_vod_category({content_id!r})")
return self._fetch_children(content_id)
# ------------------------------------------------------------------
# Root — dynamically discovered from the /home endpoint
@@ -1035,14 +1035,18 @@ class Magenta2Provider(StreamingProvider):
"accept-encoding": "gzip",
}
def get_vod_category(self, category_path, fetch_url=None, **kwargs):
def get_vod_category(self, content_id: str = "", **kwargs):
"""
Return the children of a VOD node.
Args:
content_id: Opaque node identifier produced by a previous
get_vod_category call (e.g. "lane:322341",
"series:GN_SERIES_20914057"). Empty string → root.
"""
if not self._vod_manager:
raise RuntimeError("VodManager not available - configuration discovery may have failed")
return self._vod_manager.get_children(
category_path=category_path,
fetch_url=fetch_url,
**kwargs,
)
return self._vod_manager.get_children(content_id=content_id, **kwargs)
def enrich_channel_data(
self, channel: StreamingChannel, **kwargs
@@ -38,7 +38,7 @@ tvhubs base URL resolution order
Public interface
-----------------
vod_manager.get_children(category_path, **kwargs)
vod_manager.get_children(content_id, **kwargs)
-> List[VodCategory | VodItem]
"""
@@ -127,82 +127,132 @@ class VodManager:
_platform = getattr(bootstrap, "platform", "")
self._subscriber_type: str = SUBSCRIBER_TYPES.get(_platform, "FTV_OTT_DT")
# Node registry: opaque content_id → (fetch_url, extra_params)
# Populated when lanes/series/seasons are discovered so that
# get_children can look up the full fetch context without any
# URL reconstruction or query-string manipulation.
# Lives on the provider instance (long-lived) so it survives
# across individual request-scoped calls.
self._node_registry: Dict[str, Dict] = {}
# Short-lived in-memory cache for VodDetails responses (content_id → data).
# Prevents redundant network round-trips when the same content_id is
# looked up multiple times within a single get_children() call chain
# (e.g. _map_unstructured_item → _fetch_single_item for every lane movie,
# then the provider calling get_children([gn_id]) directly to resolve
# the MPX mediaId for playback). Entries are keyed by the content_id
# string and are not persisted across VodManager instances.
# looked up multiple times within a single get_children() call chain.
self._vod_details_cache: Dict[str, Any] = {}
# =========================================================================
# Public API
# =========================================================================
def _register_node(
self,
content_id: str,
fetch_url: str,
extra_params: Optional[Dict] = None,
) -> str:
"""
Register a node in the registry and return its content_id.
Args:
content_id: Opaque identifier (e.g. "lane:322341").
fetch_url: Full URL to use when fetching children.
extra_params: Additional query params to merge (e.g. whiteLabelId).
"""
self._node_registry[content_id] = {
"fetch_url": fetch_url,
"extra_params": extra_params or {},
}
return content_id
def get_children(
self,
category_path: List[str],
content_id: str,
*,
page_size: int = VOD_DEFAULT_PAGE_SIZE,
offset: int = 0,
fetch_url: Optional[str] = None,
) -> List[Union[VodCategory, VodItem]]:
"""
Return the children of a VOD tree node.
Return the children of a VOD node identified by *content_id*.
content_id is always a single opaque token — never split on "/" by
the caller. Dispatch is by prefix:
"" → VOD home (top-level lanes)
"lane:<id>" → UnstructuredGrid lane (via registry)
"series:<id>" → GN_SERIES_… seasons
"season:<id>" → GN_SEASON_… episodes
"episode:<id>" → single episode detail
"movie:<id>" → single movie detail
legacy GN_* → backwards-compat fallback
Args:
category_path: Ordered list of content_ids from root to the
requested node, matching the provider's contract:
[] -> VOD home (top-level lanes)
["GN_SERIES_123"] -> series (seasons)
["GN_SERIES_123",
"GN_SEASON_123_DE_2"] -> season (episodes)
["lane:326619"] -> UnstructuredGrid lane
page_size: Number of items to fetch per page (UnstructuredGrid).
offset: Pagination offset (UnstructuredGrid).
fetch_url: Full URL (including portal-scoping query params such as
?whiteLabelId=megathek) to use for the fetch instead of
reconstructing it from category_path. Populated by the
caller from VodCategory.fetch_url so that params survive
the HTTP router's path-segment splitting.
Returns:
Mixed list of VodCategory and VodItem objects.
content_id: Opaque node identifier.
page_size: Items per page for lane fetches.
offset: Pagination offset for lane fetches.
"""
logger.debug(
f"{self._provider}: get_children called with "
f"category_path={category_path!r} fetch_url={fetch_url!r}"
)
logger.debug(f"{self._provider}: get_children content_id={content_id!r}")
params = self._base_params()
if not category_path:
if not content_id:
return self._fetch_home_lanes(params)
node_id = "/".join(category_path)
if node_id.startswith("UnstructuredGrid/"):
flex_id = node_id[len("UnstructuredGrid/"):].split("?")[0]
# ── Lane (UnstructuredGrid) ──────────────────────────────────────
if content_id.startswith("lane:"):
node = self._node_registry.get(content_id)
if node:
return self._fetch_lane_items(
content_id, params,
page_size=page_size, offset=offset,
fetch_url=node["fetch_url"],
extra_params=node["extra_params"],
)
# Fallback: extract bare id and construct URL
bare = content_id[len("lane:"):]
return self._fetch_lane_items(
flex_id, params,
content_id, params,
page_size=page_size, offset=offset,
fetch_url=fetch_url,
fetch_url=f"{self._base_url()}/UnstructuredGrid/{bare}",
)
if node_id.startswith("VodDetails/"):
# e.g. "VodDetails/202887/GN_SERIES_9370385" → extract the GN id
node_id = node_id.split("/")[-1]
# ── Series ──────────────────────────────────────────────────────
if content_id.startswith("series:"):
gn_id = content_id[len("series:"):]
return self._fetch_series_seasons(gn_id, params)
# ── Season ──────────────────────────────────────────────────────
if content_id.startswith("season:"):
gn_id = content_id[len("season:"):]
return self._fetch_season_episodes(gn_id, params)
# ── Episode / Movie ─────────────────────────────────────────────
if content_id.startswith("episode:"):
gn_id = content_id[len("episode:"):]
return self._fetch_single_episode(gn_id, params)
if content_id.startswith("movie:"):
gn_id = content_id[len("movie:"):]
return self._fetch_single_item(gn_id, params)
# ── Legacy / backwards-compat ───────────────────────────────────
# Support old-style content_ids (GN_SERIES_*, GN_SEASON_*, etc.)
# and path-style IDs (UnstructuredGrid/*, VodDetails/*) so that
# existing cached references keep working during transition.
node_id = content_id
if node_id.startswith("UnstructuredGrid/"):
bare = node_id[len("UnstructuredGrid/"):].split("?")[0]
return self._fetch_lane_items(
f"lane:{bare}", params,
page_size=page_size, offset=offset,
fetch_url=f"{self._base_url()}/UnstructuredGrid/{bare}",
)
if node_id.startswith("VodDetails/"):
node_id = node_id.split("/")[-1]
if node_id.startswith(VOD_PREFIX_SEASON):
return self._fetch_season_episodes(node_id, params)
if node_id.startswith(VOD_PREFIX_SERIES):
return self._fetch_series_seasons(node_id, params)
if node_id.startswith(VOD_PREFIX_EPISODE):
return self._fetch_single_episode(node_id, params)
# Movies (GN_MV, GN_SH) and any other leaf ID
return self._fetch_single_item(node_id, params)
# =========================================================================
@@ -635,15 +685,6 @@ class VodManager:
if lane_type != "UnstructuredGrid" or not flex_id or not title:
continue
# Build the content_id that will be used to fetch this lane later.
# Priority for sourcing the URL (highest to lowest):
# 1. showAllUrl.href — canonical "show all" deep-link
# 2. laneContentLink.href — always present, always has ?whiteLabelId=...
# 3. constructed from flex_id — last resort, no portal scoping params
#
# Whichever source we use, we extract everything after /UnstructuredGrid/
# (including any query string such as ?whiteLabelId=megathek) so that
# portal-scoping parameters are preserved through to the actual fetch.
show_all_href = (lane.get("showAllUrl") or {}).get("href") or None
lane_content_href = (lane.get("laneContentLink") or {}).get("href") or None
@@ -652,53 +693,29 @@ class VodManager:
f"showAllUrl={show_all_href!r} laneContentLink={lane_content_href!r}"
)
# Strategy:
# 1. showAllUrl with /UnstructuredGrid/ → extract tail (id + query)
# 2. laneContentLink with /UnstructuredGrid/ → same
# 3. Either URL with ?whiteLabelId → use flex_id + carry whiteLabelId
# 4. Nothing → bare flex_id, no portal scoping
from urllib.parse import urlparse, parse_qs, urlencode
# Best fetch URL: showAllUrl is the paginated full grid (preferred);
# laneContentLink is the inline preview but always carries portal params.
best_fetch_url = show_all_href or lane_content_href or None
def _qs_from_href(href):
"""Return URL query string params as a dict (first value only)."""
if not href:
return {}
return {k: v[0] for k, v in parse_qs(urlparse(href).query).items()}
if show_all_href and "/UnstructuredGrid/" in show_all_href:
tail = show_all_href.rstrip("/").split("/UnstructuredGrid/", 1)[1]
content_id = f"UnstructuredGrid/{tail}"
elif lane_content_href and "/UnstructuredGrid/" in lane_content_href:
tail = lane_content_href.rstrip("/").split("/UnstructuredGrid/", 1)[1]
content_id = f"UnstructuredGrid/{tail}"
else:
# laneContentLink uses a different path shape (e.g. UnstructuredGridLane/)
# but may still carry ?whiteLabelId — extract it and append to flex_id.
qs_params = _qs_from_href(lane_content_href) or _qs_from_href(show_all_href)
scoping = {k: v for k, v in qs_params.items()
if k in ("whiteLabelId",)}
if scoping:
content_id = f"UnstructuredGrid/{flex_id}?{urlencode(scoping)}"
else:
content_id = f"UnstructuredGrid/{flex_id}"
# Opaque content_id: "lane:<numeric_flex_id>" — router-safe, no slashes.
# The full fetch context lives in the registry, not in the ID.
opaque_id = f"lane:{flex_id}"
if best_fetch_url:
self._register_node(opaque_id, best_fetch_url)
logger.debug(
f"{self._provider}: Lane '{title}' → content_id={content_id!r}"
f"{self._provider}: Lane '{title}' → content_id={opaque_id!r} "
f"fetch_url={best_fetch_url!r}"
)
# Determine the best fetch_url for this lane — the full URL including
# ?whiteLabelId=... that the caller passes back via VodCategory.fetch_url.
# Priority: showAllUrl (paginated full grid) > laneContentLink (always present).
fetch_url = show_all_href or lane_content_href or None
categories.append(
VodCategory(
name=title,
content_id=content_id,
content_id=opaque_id,
provider=self._provider,
child_count=lane.get("totalCount"),
details_url=show_all_href,
fetch_url=fetch_url,
fetch_url=best_fetch_url,
)
)
@@ -711,44 +728,48 @@ class VodManager:
def _fetch_lane_items(
self,
flex_id: str,
content_id: str,
params: Dict,
page_size: int = VOD_DEFAULT_PAGE_SIZE,
offset: int = 0,
fetch_url: Optional[str] = None,
extra_params: Optional[Dict] = None,
) -> List[Union[VodCategory, VodItem]]:
"""
Fetch items from an UnstructuredGrid lane. Movies → VodItem,
series → VodCategory (seasons/episodes require further drill-down).
Fetch items from an UnstructuredGrid lane.
Args:
fetch_url: Full URL supplied by the caller from VodCategory.fetch_url,
including portal-scoping query params (e.g. ?whiteLabelId=megathek).
When present this is used as-is (base URL only, params merged
separately); when absent the URL is constructed from flex_id.
content_id: Opaque lane identifier (e.g. "lane:322341").
fetch_url: Full URL from the registry, including portal-scoping
query params. The URL's own query string is split out
and merged into paged_params so all params travel
together in one clean dict.
extra_params: Additional params from the registry (merged after
fetch_url params so they take precedence).
"""
from urllib.parse import urlparse, parse_qs, urlunparse
if fetch_url:
# Use the base path from fetch_url but merge its query params into
# paged_params so pagination ($size, $offset) and auth params are
# all sent together in one clean params dict.
parsed = urlparse(fetch_url)
url = urlunparse(parsed._replace(query=""))
url_params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
else:
url = f"{self._base_url()}/UnstructuredGrid/{flex_id}"
# Should not happen when registry is populated, but safe fallback.
bare = content_id.split(":")[-1] if ":" in content_id else content_id
url = f"{self._base_url()}/UnstructuredGrid/{bare}"
url_params = {}
paged_params = dict(params)
if url_params:
paged_params.update(url_params)
if extra_params:
paged_params.update(extra_params)
paged_params["$size"] = str(page_size)
paged_params["$offset"] = str(offset)
logger.debug(
f"{self._provider}: _fetch_lane_items flex_id={flex_id!r} "
f"params={paged_params!r}"
f"{self._provider}: _fetch_lane_items {content_id!r} url={url!r} "
f"url_params={url_params!r}"
)
data = self._get(url, paged_params)
if not data:
@@ -763,7 +784,7 @@ class VodManager:
total = content.get("page", {}).get("total", len(results))
logger.debug(
f"{self._provider}: Lane {flex_id} fetched {len(results)}/{total} items "
f"{self._provider}: Lane {content_id} fetched {len(results)}/{total} items "
f"(offset={offset})"
)
return results
@@ -791,19 +812,20 @@ class VodManager:
if vod_type == "Series":
details_href = (item.get("details") or {}).get("href") or None
series_content_id = (
details_href.split("/v3/")[1].split("?")[0].split("/", 1)[1]
if details_href and "/v3/" in details_href
else content_id
)
# Use opaque "series:<GN_SERIES_id>" — no slashes, router-safe.
gn_series_id = content_id # content_id from the lane item IS the GN id
opaque_series_id = f"series:{gn_series_id}"
if details_href:
self._register_node(opaque_series_id, details_href)
return VodCategory(
name=title,
content_id=series_content_id,
content_id=opaque_series_id,
provider=self._provider,
logo_url=image_url,
description=description,
child_count=seasons_available,
details_url=details_href,
fetch_url=details_href,
)
# Movie (or unknown leaf): resolve via _fetch_single_item so we get
@@ -896,11 +918,12 @@ class VodManager:
if season_num is None:
continue
season_title = lane.get("title") or f"Staffel {season_num}"
season_id = f"{VOD_PREFIX_SEASON}{series_num}_DE_{season_num}"
gn_season_id = f"{VOD_PREFIX_SEASON}{series_num}_DE_{season_num}"
opaque_season_id = f"season:{gn_season_id}"
seasons.append(
VodCategory(
name=season_title,
content_id=season_id,
content_id=opaque_season_id,
provider=self._provider,
description=f"{series_title} {season_title}",
child_count=lane.get("episodeCount") or lane.get("totalCount"),
@@ -965,14 +988,10 @@ class VodManager:
# content_id: strip base URL and query string from details href
# e.g. "https://.../VodDetails/202887/GN_SEASON_184925_DE_1?..."
# → "VodDetails/202887/GN_SEASON_184925_DE_1"
if details_href and "/v3/" in details_href:
content_id = (
details_href.split("/v3/")[1]
.split("?")[0]
.split("/", 1)[1]
)
else:
content_id = season_id
# Opaque season ID — router-safe, no slashes.
opaque_season_id = f"season:{season_id}"
if details_href:
self._register_node(opaque_season_id, details_href)
image_url: Optional[str] = (item.get("image") or {}).get("href")
description: Optional[str] = (
@@ -983,12 +1002,13 @@ class VodManager:
seasons.append(
VodCategory(
name=title,
content_id=content_id,
content_id=opaque_season_id,
provider=self._provider,
logo_url=image_url,
description=description or f"{series_title} {title}",
child_count=episode_count,
details_url=details_href,
fetch_url=details_href,
)
)
if seasons:
@@ -265,9 +265,9 @@ class RTLPlusProvider(StreamingProvider):
return events
def get_vod_category(self, category_path, **kwargs):
def get_vod_category(self, content_id: str = "", **kwargs):
"""Delegate VOD browsing to RTLPlusVodManager."""
return self._vod_manager.get_vod_category(category_path, **kwargs)
return self._vod_manager.get_vod_category(content_id=content_id, **kwargs)
def _parse_station_to_channel(self, station: Dict) -> Optional[StreamingChannel]:
"""
@@ -89,98 +89,58 @@ class RTLPlusVodManager:
# ------------------------------------------------------------------
def get_vod_category(
self, category_path: List[str], **kwargs
self, content_id: str = "", **kwargs
) -> List[Union[VodCategory, VodItem]]:
"""
Return the children of the VOD node identified by *category_path*.
Return the children of the VOD node identified by *content_id*.
Routing logic
-------------
Depth 0 root: Movies node + Series node + TopicWorlds
Depth 1, movies Genre categories under /video-tv/filme
Depth 1, series Genre categories under /video-tv/serien
Depth 1, topic Format/Movie list for a TopicWorld
Depth 2, movies Movie VodItems for a genre (OverviewPage MOVIE)
Depth 2, series-genre Series VodItems for a genre (OverviewPage SERIES)
Depth 2, topic Season list for a Format
Depth 3 Episode list for a Season
content_id is a single opaque token never split by the caller.
Dispatch is by prefix:
Path normalisation
------------------
Callers may pass the watch-path either as a single element
("/video-tv/serien") or pre-split without a leading slash
(["video-tv", "serien"]). We reassemble and re-prefix so that all
downstream checks see a consistent leading-slash watch-path in [0].
"" root
"/video-tv/filme" movies branch
"/video-tv/serien" series branch
"/video-tv/shows" shows branch
"rrn:watch:videohub:format:*" list seasons for format
"rrn:watch:videohub:season:*" list episodes for season
"rrn:multipurpose:*" list formats for topic world
"movies-genre:*" movie genre items
"series-genre:*" series genre items
"season:*" episodes for season key
"""
# Reassemble paths that were split on "/" without a leading slash,
# e.g. ["video-tv", "serien"] → ["/video-tv/serien"]
# Leave RRNs ("rrn:…") and already-slash-prefixed paths untouched.
if (
category_path
and not category_path[0].startswith("/")
and not category_path[0].startswith("rrn:")
and not category_path[0].startswith("season:")
and not category_path[0].startswith("movies-genre:")
and not category_path[0].startswith("series-genre:")
):
# Rejoin all segments as a single watch-path element
category_path = ["/" + "/".join(category_path)]
depth = len(category_path)
if depth == 0:
if not content_id:
return self._list_root()
# Movies branch — watch-path based IDs
if category_path[0].startswith(RTLPlusDefaults.VOD_MOVIES_ROOT_WATCH_PATH):
return self._dispatch_movies(category_path)
if content_id.startswith(RTLPlusDefaults.VOD_MOVIES_ROOT_WATCH_PATH):
return self._dispatch_movies([content_id])
# Series branch — watch-path based IDs
if category_path[0].startswith(RTLPlusDefaults.VOD_SERIES_ROOT_WATCH_PATH):
return self._dispatch_series(category_path)
if content_id.startswith(RTLPlusDefaults.VOD_SERIES_ROOT_WATCH_PATH):
return self._dispatch_series([content_id])
# Shows branch — watch-path based IDs
if category_path[0].startswith(RTLPlusDefaults.VOD_SHOWS_ROOT_WATCH_PATH):
return self._dispatch_shows(category_path)
if content_id.startswith(RTLPlusDefaults.VOD_SHOWS_ROOT_WATCH_PATH):
return self._dispatch_shows([content_id])
# RRN-namespace routing — dispatch by the kind of RRN, not depth.
# This handles format/season RRNs returned by OverviewPage for
# shows and serien (content_id = rrn:watch:videohub:format:* or season:*).
first = category_path[0]
if first.startswith("rrn:watch:videohub:format:"):
# Format RRN → list seasons
return self._list_seasons(first)
if content_id.startswith("rrn:watch:videohub:format:"):
return self._list_seasons(content_id)
if first.startswith("rrn:watch:videohub:season:"):
# Season RRN → list episodes via SeasonWithFormatAndEpisodes
return self._list_episodes_for_season(first)
if content_id.startswith("rrn:watch:videohub:season:"):
return self._list_episodes_for_season(content_id)
# TopicWorlds branch — rrn:multipurpose:* at depth 1
if depth == 1 and first.startswith("rrn:"):
return self._list_formats_for_topic(first)
if content_id.startswith("rrn:"):
return self._list_formats_for_topic(content_id)
if depth == 2:
format_rrn = self._to_format_rrn(category_path[1])
if not format_rrn.startswith("rrn:"):
logger.warning(
f"RTLPlusVodManager: invalid format RRN at depth 2: {format_rrn!r} "
f"(path={category_path!r}) — skipping"
)
return []
return self._list_seasons(format_rrn)
if content_id.startswith("movies-genre:") or content_id.startswith("series-genre:"):
return self._dispatch_movies([content_id]) if content_id.startswith("movies-genre:") else self._dispatch_series([content_id])
if depth == 3:
format_rrn = self._to_format_rrn(category_path[1])
if not format_rrn.startswith("rrn:"):
logger.warning(
f"RTLPlusVodManager: invalid format RRN at depth 3: {format_rrn!r} "
f"(path={category_path!r}) — skipping"
)
return []
season_key = category_path[2]
return self._list_episodes(format_rrn, season_key)
if content_id.startswith("season:"):
# season:<format_rrn>/<season_key> — encoded as a single token
# e.g. "season:rrn:watch:videohub:format:123/s1"
rest = content_id[len("season:"):]
if "/" in rest:
format_rrn, season_key = rest.split("/", 1)
return self._list_episodes(format_rrn, season_key)
logger.warning(f"RTLPlusVodManager: path too deep: {category_path!r}")
logger.warning(f"RTLPlusVodManager: unrecognised content_id: {content_id!r}")
return []
# ------------------------------------------------------------------
+24 -11
View File
@@ -43,7 +43,7 @@ def setup_vod_routes(app, manager):
@app.route("/api/providers/<provider>/vod", method="GET")
def get_vod_root(provider):
try:
entries = manager.get_vod_node(provider_name=provider, slug_segments=[])
entries = manager.get_vod_node(provider_name=provider, content_id="")
except ValueError as e:
response.status = 404
return {"error": "Provider not found", "message": str(e), "provider": provider}
@@ -53,22 +53,35 @@ def setup_vod_routes(app, manager):
return {"error": "Failed to get VOD entries", "message": str(e), "provider": provider}
serialized = _serialize(entries)
response.status = 200
return {"provider": provider, "path": "", "entries": serialized, "count": len(serialized)}
return {"provider": provider, "content_id": "", "entries": serialized, "count": len(serialized)}
@app.route("/api/providers/<provider>/vod/<path:path>", method="GET")
def get_vod_path(provider, path):
slug_segments = [s for s in path.split("/") if s]
if not slug_segments:
@app.route("/api/providers/<provider>/vod/<content_id>", method="GET")
def get_vod_node(provider, content_id):
"""
Navigate to any VOD node by its opaque content_id.
content_id is treated as a single opaque token never split or parsed.
It is the value returned in VodCategory.content_id from a previous response.
Examples:
GET /api/providers/magenta2/vod/lane%3A322341
GET /api/providers/magenta2/vod/series%3AGN_SERIES_20914057
GET /api/providers/discovery_de/vod/sports
"""
if not content_id:
return get_vod_root(provider)
try:
entries = manager.get_vod_node(provider_name=provider, slug_segments=slug_segments)
entries = manager.get_vod_node(
provider_name=provider,
content_id=content_id,
)
except ValueError as e:
response.status = 404
return {"error": "Not found", "message": str(e), "provider": provider, "path": path}
return {"error": "Not found", "message": str(e), "provider": provider, "content_id": content_id}
except Exception as e:
logger.error(f"Failed to get VOD path from provider: {e}")
logger.error(f"Failed to get VOD node from provider: {e}")
response.status = 500
return {"error": "Failed to get VOD entries", "message": str(e), "provider": provider, "path": path}
return {"error": "Failed to get VOD entries", "message": str(e), "provider": provider, "content_id": content_id}
serialized = _serialize(entries)
response.status = 200
return {"provider": provider, "path": path, "entries": serialized, "count": len(serialized)}
return {"provider": provider, "content_id": content_id, "entries": serialized, "count": len(serialized)}