mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-04 23:12:38 +02:00
Add discovery
This commit is contained in:
@@ -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)
|
||||
# ==========================================================================
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user