Add discovery

This commit is contained in:
Nirvana
2026-03-03 15:24:56 +01:00
parent 753ac4843f
commit 8c38aa1106
6 changed files with 544 additions and 1 deletions
+18
View File
@@ -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)
# ==========================================================================
+265
View File
@@ -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,
)
+37 -1
View File
@@ -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)
@@ -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
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
# routes/vod.py
"""
VOD (Video on Demand) route handlers.
Browse endpoints
----------------
GET /api/providers/<provider>/vod
Returns the root-level VOD entries (categories and/or items).
GET /api/providers/<provider>/vod/<path:path>
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/<provider>/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/<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:
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)}
+2
View File
@@ -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")