mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-23 01:22:17 +02:00
Add discovery
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
Reference in New Issue
Block a user