From fa9c29f95e138ca93214000cb3a404fe55b3e7c2 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Wed, 18 Mar 2026 19:14:50 +0100 Subject: [PATCH] Add discovery --- lib/streaming_providers/base/provider.py | 23 +- .../base/vod_operations.py | 75 +---- .../providers/discovery/provider.py | 17 +- .../providers/discovery/vod_manager.py | 40 +-- .../providers/magenta2/provider.py | 16 +- .../providers/magenta2/vod_manager.py | 260 ++++++++++-------- .../providers/rtlplus/provider.py | 4 +- .../providers/rtlplus/vod_manager.py | 114 +++----- routes/vod.py | 35 ++- 9 files changed, 240 insertions(+), 344 deletions(-) diff --git a/lib/streaming_providers/base/provider.py b/lib/streaming_providers/base/provider.py index 8edd3fb..1b70b08 100644 --- a/lib/streaming_providers/base/provider.py +++ b/lib/streaming_providers/base/provider.py @@ -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. diff --git a/lib/streaming_providers/base/vod_operations.py b/lib/streaming_providers/base/vod_operations.py index 3491d0b..c86b2e9 100644 --- a/lib/streaming_providers/base/vod_operations.py +++ b/lib/streaming_providers/base/vod_operations.py @@ -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 diff --git a/lib/streaming_providers/providers/discovery/provider.py b/lib/streaming_providers/providers/discovery/provider.py index 6f7c319..68208be 100644 --- a/lib/streaming_providers/providers/discovery/provider.py +++ b/lib/streaming_providers/providers/discovery/provider.py @@ -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, diff --git a/lib/streaming_providers/providers/discovery/vod_manager.py b/lib/streaming_providers/providers/discovery/vod_manager.py index e77235a..323683c 100644 --- a/lib/streaming_providers/providers/discovery/vod_manager.py +++ b/lib/streaming_providers/providers/discovery/vod_manager.py @@ -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 diff --git a/lib/streaming_providers/providers/magenta2/provider.py b/lib/streaming_providers/providers/magenta2/provider.py index cc1fbb2..806028d 100644 --- a/lib/streaming_providers/providers/magenta2/provider.py +++ b/lib/streaming_providers/providers/magenta2/provider.py @@ -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 diff --git a/lib/streaming_providers/providers/magenta2/vod_manager.py b/lib/streaming_providers/providers/magenta2/vod_manager.py index 5a1c0e2..afed9bc 100644 --- a/lib/streaming_providers/providers/magenta2/vod_manager.py +++ b/lib/streaming_providers/providers/magenta2/vod_manager.py @@ -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:" → UnstructuredGrid lane (via registry) + "series:" → GN_SERIES_… seasons + "season:" → GN_SEASON_… episodes + "episode:" → single episode detail + "movie:" → 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:" — 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:" — 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: diff --git a/lib/streaming_providers/providers/rtlplus/provider.py b/lib/streaming_providers/providers/rtlplus/provider.py index 0e05343..182f8ec 100644 --- a/lib/streaming_providers/providers/rtlplus/provider.py +++ b/lib/streaming_providers/providers/rtlplus/provider.py @@ -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]: """ diff --git a/lib/streaming_providers/providers/rtlplus/vod_manager.py b/lib/streaming_providers/providers/rtlplus/vod_manager.py index 6ffccd9..543c0df 100644 --- a/lib/streaming_providers/providers/rtlplus/vod_manager.py +++ b/lib/streaming_providers/providers/rtlplus/vod_manager.py @@ -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:/ — 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 [] # ------------------------------------------------------------------ diff --git a/routes/vod.py b/routes/vod.py index 5e4b876..5bb009d 100644 --- a/routes/vod.py +++ b/routes/vod.py @@ -43,7 +43,7 @@ def setup_vod_routes(app, manager): @app.route("/api/providers//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//vod/", 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//vod/", 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)} \ No newline at end of file + return {"provider": provider, "content_id": content_id, "entries": serialized, "count": len(serialized)} \ No newline at end of file