From 8c38aa1106faa43e4ab81203b069070f71cb1d7a Mon Sep 17 00:00:00 2001 From: Nirvana Date: Tue, 3 Mar 2026 15:24:56 +0100 Subject: [PATCH] Add discovery --- lib/streaming_providers/base/manager.py | 18 ++ lib/streaming_providers/base/models/vod.py | 265 ++++++++++++++++++ lib/streaming_providers/base/provider.py | 38 ++- .../base/vod_operations.py | 152 ++++++++++ routes/vod.py | 70 +++++ service.py | 2 + 6 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 lib/streaming_providers/base/models/vod.py create mode 100644 lib/streaming_providers/base/vod_operations.py create mode 100644 routes/vod.py diff --git a/lib/streaming_providers/base/manager.py b/lib/streaming_providers/base/manager.py index 3bc78b3..1d38998 100644 --- a/lib/streaming_providers/base/manager.py +++ b/lib/streaming_providers/base/manager.py @@ -11,6 +11,7 @@ from .channel_operations import ChannelOperations from .drm_operations import DRMOperations from .epg_operations import EPGOperations from .event_operations import EventOperations +from .vod_operations import VodOperations from .models import StreamingChannel from .provider_registry import ProviderRegistry from .subscription_operations import SubscriptionOperations @@ -34,6 +35,7 @@ class ProviderManager: self.catchup_ops = CatchupOperations(self.registry, self.drm_ops) self.subscription_ops = SubscriptionOperations(self.registry) self.event_ops = EventOperations(self.registry) + self.vod_ops = VodOperations(self.registry) # Backward compatibility - expose managers directly self.drm_plugin_manager = self.drm_ops.drm_plugin_manager @@ -230,6 +232,22 @@ class ProviderManager: def get_event_drm_configs(self, provider_name: str, event_id: str, **kwargs) -> List: return self.drm_ops.get_content_drm_configs(provider_name, event_id, **kwargs) + # ========================================================================== + # VOD OPERATIONS (delegate to VodOperations) + # ========================================================================== + + def get_vod_node(self, provider_name: str, slug_segments: list) -> list: + return self.vod_ops.get_vod_node(provider_name, slug_segments) + + def get_vod_manifest(self, provider_name: str, vod_id: str, **kwargs): + return self.vod_ops.get_vod_manifest(provider_name, vod_id, **kwargs) + + def get_vod_drm_configs(self, provider_name: str, vod_id: str, **kwargs) -> list: + return self.drm_ops.get_content_drm_configs(provider_name, vod_id, **kwargs) + + def get_all_vod_roots(self) -> dict: + return self.vod_ops.get_all_vod_roots() + # ========================================================================== # SUBSCRIPTION OPERATIONS (delegate to SubscriptionOperations) # ========================================================================== diff --git a/lib/streaming_providers/base/models/vod.py b/lib/streaming_providers/base/models/vod.py new file mode 100644 index 0000000..e6bcb00 --- /dev/null +++ b/lib/streaming_providers/base/models/vod.py @@ -0,0 +1,265 @@ +# streaming_providers/base/models/vod.py +""" +VOD (Video on Demand) models. + +Two types: + VodCategory — a browsable node (directory, collection, series, season, …). + Never directly playable. + VodItem — a playable leaf (movie, episode, documentary, …). + Inherits full Content machinery; manifest/DRM resolved via + get_manifest(content_id) / get_drm(content_id) like channels + and events. +""" + +import re +import unicodedata +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +from .content import Content +from ..utils.logger import logger + + +# --------------------------------------------------------------------------- +# Slug utilities +# --------------------------------------------------------------------------- + +def slugify(text: str) -> str: + """ + Convert an arbitrary string to a URL-safe slug. + + Steps: + 1. Unicode normalise (NFKD) and drop combining characters + 2. Lowercase + 3. Replace whitespace and common separators with underscores + 4. Strip everything that is not alphanumeric or underscore/hyphen + 5. Collapse consecutive underscores/hyphens + 6. Strip leading/trailing underscores and hyphens + """ + # 1. Normalise unicode + text = unicodedata.normalize("NFKD", text) + text = "".join(c for c in text if not unicodedata.combining(c)) + # 2. Lowercase + text = text.lower() + # 3. Whitespace / separators → underscore + text = re.sub(r"[\s\-–—/\\|]+", "_", text) + # 4. Keep only safe characters + text = re.sub(r"\W", "", text) + # 5. Collapse runs + text = re.sub(r"_+", "_", text) + # 6. Strip edges + return text.strip("_-") + + +def build_slug_map(entries: list) -> Dict[str, str]: + """ + Build a slug → content_id mapping for a list of VodCategory / VodItem + entries, falling back to the content_id itself as the slug when two + siblings would produce the same slug. + + Returns: + {slug: content_id} + """ + # First pass: compute preferred slug for every entry + preferred: List[tuple] = [] + for entry in entries: + preferred.append((slugify(entry.name), entry.content_id)) + + # Detect collisions + seen_slugs: Dict[str, int] = {} + for slug, _ in preferred: + seen_slugs[slug] = seen_slugs.get(slug, 0) + 1 + + slug_map: Dict[str, str] = {} + for slug, content_id in preferred: + if seen_slugs[slug] > 1: + # Fall back to the raw ID as the slug + final_slug = slugify(content_id) or content_id + logger.debug( + f"VOD slug collision for '{slug}' — using id '{final_slug}' instead" + ) + else: + final_slug = slug + slug_map[final_slug] = content_id + + return slug_map + + +# --------------------------------------------------------------------------- +# VodCategory +# --------------------------------------------------------------------------- + +@dataclass +class VodCategory: + """ + A browsable VOD node — category, collection, series, season, etc. + + Not playable. Resolved by calling provider.get_vod_category(path_ids). + content_id is used as the path segment ID when navigating deeper. + """ + + # Required + name: str + content_id: str + provider: str + + # Optional metadata + logo_url: Optional[str] = None + description: Optional[str] = None + + # Hint about how many children this node has (may be None if unknown) + child_count: Optional[int] = None + + # Cached slug (computed lazily if not set) + _slug: Optional[str] = field(default=None, repr=False) + + @property + def slug(self) -> str: + if not self._slug: + self._slug = slugify(self.name) + return self._slug + + @property + def node_type(self) -> str: + return "vod_category" + + def to_dict(self) -> Dict: + return { + "type": self.node_type, + "id": self.content_id, + "name": self.name, + "slug": self.slug, + "provider": self.provider, + "logo_url": self.logo_url, + "description": self.description, + "child_count": self.child_count, + } + + +# --------------------------------------------------------------------------- +# VodItem +# --------------------------------------------------------------------------- + +@dataclass +class VodItem(Content): + """ + A playable VOD leaf — movie, episode, documentary, past sports event, etc. + + Inherits all streaming/DRM fields from Content. + Manifest and DRM are resolved identically to channels and events: + provider.get_manifest(content_id) + provider.get_drm(content_id) + """ + + # Timing + duration_seconds: Optional[int] = None + release_year: Optional[int] = None + + # Classification + rating: Optional[str] = None # e.g. "FSK 12", "PG-13", "TV-MA" + genre: Optional[str] = None # already on Content but repeated for clarity + + # People + cast: Optional[List[str]] = None + director: Optional[str] = None + + # Series / episode context (None for standalone movies / documentaries) + season_number: Optional[int] = None + episode_number: Optional[int] = None + + # Promotional + trailer_url: Optional[str] = None + + # Cached slug + _slug: Optional[str] = field(default=None, repr=False) + + def __post_init__(self): + # Ensure mode and content_type are set correctly for on-demand content + if self.mode == "live": + self.mode = "vod" + if self.content_type == "LIVE": + self.content_type = "VOD" + + @property + def slug(self) -> str: + if not self._slug: + self._slug = slugify(self.name) + return self._slug + + @property + def node_type(self) -> str: + return "vod" + + @property + def is_episode(self) -> bool: + return self.season_number is not None or self.episode_number is not None + + @property + def duration_minutes(self) -> Optional[int]: + if self.duration_seconds is not None: + return self.duration_seconds // 60 + return None + + def to_dict(self) -> Dict: + result = super().to_dict() + result.update({ + "type": self.node_type, + "slug": self.slug, + "duration_seconds": self.duration_seconds, + "duration_minutes": self.duration_minutes, + "release_year": self.release_year, + "rating": self.rating, + "cast": self.cast, + "director": self.director, + "season_number": self.season_number, + "episode_number": self.episode_number, + "trailer_url": self.trailer_url, + }) + return result + + def validate(self) -> List[str]: + warnings = [] + if not self.manifest and not self.manifest_script: + warnings.append("No manifest URL or manifest script provided") + if self.license_url and not self.drm_config: + warnings.append("License URL provided but no DRM configuration") + if self.duration_seconds is not None and self.duration_seconds <= 0: + warnings.append("duration_seconds must be positive") + if self.release_year is not None and not (1888 <= self.release_year <= 2100): + warnings.append(f"Unusual release_year: {self.release_year}") + return warnings + + # Factory methods + @classmethod + def create_movie( + cls, name: str, content_id: str, provider: str, **kwargs + ) -> "VodItem": + return cls( + name=name, + content_id=content_id, + provider=provider, + mode="vod", + content_type="MOVIE", + **kwargs, + ) + + @classmethod + def create_episode( + cls, + name: str, + content_id: str, + provider: str, + season_number: int, + episode_number: int, + **kwargs, + ) -> "VodItem": + return cls( + name=name, + content_id=content_id, + provider=provider, + mode="vod", + content_type="SERIES", + season_number=season_number, + episode_number=episode_number, + **kwargs, + ) \ No newline at end of file diff --git a/lib/streaming_providers/base/provider.py b/lib/streaming_providers/base/provider.py index 15fe751..dd9dfe1 100644 --- a/lib/streaming_providers/base/provider.py +++ b/lib/streaming_providers/base/provider.py @@ -653,6 +653,42 @@ class StreamingProvider(ABC): except Exception as e: logger.error(f"{self.provider_name}: Error getting {token_type} token: {e}") return None + # ========================================================================= + # VOD + # ========================================================================= + + @property + def implements_vod(self) -> bool: + """ + Indicates whether this provider has a browsable VOD catalogue. + + Return False (and let get_vod_category return []) for providers that + only offer live channels or events. VodOperations will skip providers + where this returns False when aggregating across all providers. + """ + return False + + def get_vod_category( + self, category_path: List[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. + + Returns: + Mixed list of VodCategory and VodItem objects. + Return [] if the node has no children or VOD is not supported. + """ + return [] # ============================================================================ # CATCHUP ABSTRACT METHODS @@ -670,7 +706,7 @@ class StreamingProvider(ABC): Get manifest URL for catchup/timeshift content. Args: - channel_id: Channel identifier + content_id: Channel identifier start_time: Start time as Unix timestamp end_time: End time as Unix timestamp epg_id: Optional EPG event ID (might be needed by some providers) diff --git a/lib/streaming_providers/base/vod_operations.py b/lib/streaming_providers/base/vod_operations.py new file mode 100644 index 0000000..de29314 --- /dev/null +++ b/lib/streaming_providers/base/vod_operations.py @@ -0,0 +1,152 @@ +# streaming_providers/base/vod_operations.py +""" +VOD-related operations separated from core registry. +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 .utils.logger import logger + + +class VodOperations: + """Handles all VOD browsing and manifest operations.""" + + def __init__(self, registry): + self.registry = registry + logger.debug("VodOperations: Initialized") + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _get_provider(self, provider_name: str): + provider = self.registry.get_provider(provider_name) + if not provider: + 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 + # ------------------------------------------------------------------ + + def get_vod_node( + self, + provider_name: str, + slug_segments: List[str], + ) -> List[Union[VodCategory, VodItem]]: + """ + Resolve a slug path and return the children of that node. + + Args: + provider_name: Provider to query. + slug_segments: URL path segments as slugs, e.g. + ["sports", "golf", "pga"]. + Empty list → root level. + + 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). + """ + 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 + + # Resolve slugs → ids, then fetch the final node's children + resolved_ids = self._resolve_path_to_ids(provider_name, slug_segments) + children = provider.get_vod_category(resolved_ids) + + logger.info( + f"Retrieved {len(children)} VOD entries from '{provider_name}' " + f"at path '{'/'.join(slug_segments)}'" + ) + return children + + def get_vod_manifest( + self, provider_name: str, vod_id: str, **kwargs + ) -> Optional[str]: + """ + Get manifest URL for a specific VOD item. + + Delegates directly to provider.get_manifest(content_id) — identical + to channel and event manifest resolution. + """ + provider = self._get_provider(provider_name) + manifest_url = provider.get_manifest(content_id=vod_id, **kwargs) + if manifest_url: + logger.debug( + f"Retrieved manifest for VOD '{vod_id}' from '{provider_name}'" + ) + return manifest_url + + def get_all_vod_roots(self) -> Dict[str, List[Union[VodCategory, VodItem]]]: + """ + Get root VOD entries from all enabled providers that implement VOD. + + Providers that return implements_vod=False are silently skipped. + """ + enabled = self.registry.get_enabled_providers() + logger.info(f"Fetching VOD roots from {len(enabled)} providers") + + result = {} + total = 0 + + for name in enabled: + try: + provider = self.registry.get_provider(name) + if not getattr(provider, "implements_vod", False): + continue + entries = self.get_vod_node(name, []) + result[name] = entries + total += len(entries) + except Exception as e: + logger.error(f"Failed to get VOD root from '{name}': {e}") + result[name] = [] + + logger.info(f"Retrieved {total} total root VOD entries") + return result \ No newline at end of file diff --git a/routes/vod.py b/routes/vod.py new file mode 100644 index 0000000..66dc63e --- /dev/null +++ b/routes/vod.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# routes/vod.py +""" +VOD (Video on Demand) route handlers. + +Browse endpoints +---------------- +GET /api/providers//vod + Returns the root-level VOD entries (categories and/or items). + +GET /api/providers//vod/ + Navigates the VOD tree by URL-safe name slugs. + e.g. /api/providers/myprovider/vod/sports/golf/pga/tournament_x + + Response: + { + "provider": "myprovider", + "path": "sports/golf/pga", + "entries": [ + {"type": "vod_category", "id": "...", "name": "...", "slug": "..."}, + {"type": "vod", "id": "...", "name": "...", "slug": "..."} + ], + "count": 12 + } + +Stream/manifest/DRM endpoints for VodItem are registered in streams.py +following the identical pattern used for channels and events. +""" + +from bottle import response +from streaming_providers.base.utils import logger + + +def setup_vod_routes(app, manager): + + def _serialize(entries) -> list: + return [e.to_dict() for e in entries] + + @app.route("/api/providers//vod", method="GET") + def get_vod_root(provider): + try: + entries = manager.vod_ops.get_vod_node(provider_name=provider, slug_segments=[]) + except ValueError as e: + response.status = 404 + return {"error": "Provider not found", "message": str(e), "provider": provider} + except Exception as e: + logger.error(f"Failed to get VOD root from provider: {e}") + response.status = 500 + 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)} + + @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: + return get_vod_root(provider) + try: + entries = manager.vod_ops.get_vod_node(provider_name=provider, slug_segments=slug_segments) + except ValueError as e: + response.status = 404 + return {"error": "Not found", "message": str(e), "provider": provider, "path": path} + except Exception as e: + logger.error(f"Failed to get VOD path from provider: {e}") + response.status = 500 + return {"error": "Failed to get VOD entries", "message": str(e), "provider": provider, "path": path} + serialized = _serialize(entries) + response.status = 200 + return {"provider": provider, "path": path, "entries": serialized, "count": len(serialized)} \ No newline at end of file diff --git a/service.py b/service.py index 9d63c20..94b4b97 100644 --- a/service.py +++ b/service.py @@ -1616,6 +1616,7 @@ class UltimateService: from routes.config import setup_config_routes from routes.epg import setup_epg_routes from routes.events import setup_events_routes + from routes.vod import setup_vod_routes # Setup routes from separate modules setup_provider_routes(self.app, self.manager, self) @@ -1626,6 +1627,7 @@ class UltimateService: setup_config_routes(self.app, self.manager, self) setup_epg_routes(self.app, self.manager, self) setup_events_routes(self.app, self.manager, self) + setup_vod_routes(self.app, self.manager) # Core UI routes @self.app.route("/config")