mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-30 21:12:21 +02:00
Improve EPG
This commit is contained in:
@@ -426,6 +426,29 @@ class StreamingProvider(ABC):
|
||||
"""Get all DRM configurations for a channel by ID"""
|
||||
return []
|
||||
|
||||
@property
|
||||
def catchup_window(self) -> int:
|
||||
"""
|
||||
Return the catchup window in days for this provider.
|
||||
|
||||
Returns:
|
||||
int: Number of days of catchup available (0 = no catchup support)
|
||||
|
||||
Override in subclass to provide provider-specific catchup window.
|
||||
Default is 0 (no catchup).
|
||||
"""
|
||||
return 0
|
||||
|
||||
@property
|
||||
def supports_catchup(self) -> bool:
|
||||
"""
|
||||
Check if provider supports catchup/timeshift functionality.
|
||||
|
||||
Returns:
|
||||
bool: True if catchup is supported
|
||||
"""
|
||||
return self.catchup_window > 0
|
||||
|
||||
def get_epg(self, channel_id: str,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
@@ -465,4 +488,217 @@ class StreamingProvider(ABC):
|
||||
|
||||
def to_json(self, channels: List[StreamingChannel] = None, indent: int = 2) -> str:
|
||||
"""Convert to JSON string"""
|
||||
return json.dumps(self.to_output_format(channels), indent=indent, ensure_ascii=False)
|
||||
return json.dumps(self.to_output_format(channels), indent=indent, ensure_ascii=False)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CATCHUP ABSTRACT METHODS
|
||||
# ============================================================================
|
||||
|
||||
def get_catchup_manifest(self,
|
||||
channel_id: str,
|
||||
start_time: int,
|
||||
end_time: int,
|
||||
epg_id: Optional[str] = None,
|
||||
**kwargs) -> Optional[str]:
|
||||
"""
|
||||
Get manifest URL for catchup/timeshift content.
|
||||
|
||||
Args:
|
||||
channel_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)
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
Manifest URL for catchup content, or None if not supported
|
||||
|
||||
Default implementation falls back to live manifest.
|
||||
Override in subclass to implement provider-specific catchup logic.
|
||||
"""
|
||||
if not self.supports_catchup:
|
||||
logger.debug(f"{self.provider_name}: Catchup not supported, falling back to live manifest")
|
||||
return self.get_manifest(channel_id, **kwargs)
|
||||
|
||||
logger.warning(f"{self.provider_name}: get_catchup_manifest not implemented, "
|
||||
f"falling back to live manifest")
|
||||
return self.get_manifest(channel_id, **kwargs)
|
||||
|
||||
def get_catchup_drm(self,
|
||||
channel_id: str,
|
||||
start_time: int,
|
||||
end_time: int,
|
||||
epg_id: Optional[str] = None,
|
||||
**kwargs) -> List[DRMConfig]:
|
||||
"""
|
||||
Get DRM configurations for catchup content.
|
||||
|
||||
Args:
|
||||
channel_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 for DRM licensing)
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
List of DRM configurations for catchup content
|
||||
|
||||
Default implementation falls back to live DRM.
|
||||
Override in subclass if catchup requires different DRM configuration.
|
||||
"""
|
||||
if not self.supports_catchup:
|
||||
logger.debug(f"{self.provider_name}: Catchup not supported, falling back to live DRM")
|
||||
return self.get_drm(channel_id, **kwargs)
|
||||
|
||||
logger.debug(f"{self.provider_name}: get_catchup_drm not implemented, "
|
||||
f"using live DRM configuration")
|
||||
return self.get_drm(channel_id, **kwargs)
|
||||
|
||||
# ============================================================================
|
||||
# CATCHUP HELPER METHODS
|
||||
# ============================================================================
|
||||
|
||||
def validate_catchup_request(self,
|
||||
start_time: int,
|
||||
end_time: int) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate a catchup request against provider's capabilities.
|
||||
|
||||
Args:
|
||||
start_time: Start time as Unix timestamp
|
||||
end_time: End time as Unix timestamp
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
If valid: (True, None)
|
||||
If invalid: (False, "error description")
|
||||
"""
|
||||
import time
|
||||
|
||||
# Check if catchup is supported
|
||||
if not self.supports_catchup:
|
||||
return False, f"Provider '{self.provider_name}' does not support catchup"
|
||||
|
||||
# Validate timestamps
|
||||
if start_time >= end_time:
|
||||
return False, "Invalid time range: start_time must be before end_time"
|
||||
|
||||
# Check if request is in the future
|
||||
now = int(time.time())
|
||||
if start_time > now:
|
||||
return False, "Cannot request future content"
|
||||
|
||||
# Check if content is within catchup window
|
||||
max_age_seconds = self.catchup_window * 86400 # days to seconds
|
||||
content_age = now - start_time
|
||||
|
||||
if content_age > max_age_seconds:
|
||||
return False, (f"Content is outside catchup window "
|
||||
f"(requested: {content_age // 86400} days ago, "
|
||||
f"max: {self.catchup_window} days)")
|
||||
|
||||
return True, None
|
||||
|
||||
def get_catchup_window_for_channel(self, channel_id: str) -> int:
|
||||
"""
|
||||
Get catchup window for a specific channel.
|
||||
|
||||
Some providers might have different catchup windows for different channels.
|
||||
Default implementation returns the provider-wide catchup window.
|
||||
|
||||
Args:
|
||||
channel_id: Channel identifier
|
||||
|
||||
Returns:
|
||||
int: Catchup window in days for this channel
|
||||
|
||||
Override in subclass if channels have different catchup windows.
|
||||
"""
|
||||
return self.catchup_window
|
||||
|
||||
def format_catchup_time_params(self,
|
||||
start_time: int,
|
||||
end_time: int,
|
||||
format_type: str = 'iso') -> Dict[str, str]:
|
||||
"""
|
||||
Format time parameters for provider-specific API calls.
|
||||
|
||||
Different providers expect different time formats in their APIs.
|
||||
This helper converts Unix timestamps to various formats.
|
||||
|
||||
Args:
|
||||
start_time: Start time as Unix timestamp
|
||||
end_time: End time as Unix timestamp
|
||||
format_type: Format type ('iso', 'unix', 'millis', 'custom')
|
||||
|
||||
Returns:
|
||||
Dictionary with formatted time parameters
|
||||
|
||||
Override in subclass for provider-specific formatting.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
if format_type == 'iso':
|
||||
# ISO 8601 format
|
||||
start_dt = datetime.fromtimestamp(start_time)
|
||||
end_dt = datetime.fromtimestamp(end_time)
|
||||
return {
|
||||
'start': start_dt.isoformat(),
|
||||
'end': end_dt.isoformat()
|
||||
}
|
||||
elif format_type == 'unix':
|
||||
# Unix timestamps (seconds)
|
||||
return {
|
||||
'start': str(start_time),
|
||||
'end': str(end_time)
|
||||
}
|
||||
elif format_type == 'millis':
|
||||
# Milliseconds since epoch
|
||||
return {
|
||||
'start': str(start_time * 1000),
|
||||
'end': str(end_time * 1000)
|
||||
}
|
||||
else:
|
||||
# Default to unix
|
||||
return {
|
||||
'start': str(start_time),
|
||||
'end': str(end_time)
|
||||
}
|
||||
|
||||
def build_catchup_manifest_url(self,
|
||||
base_url: str,
|
||||
start_time: int,
|
||||
end_time: int,
|
||||
url_format: str = 'query') -> str:
|
||||
"""
|
||||
Build catchup manifest URL with time parameters.
|
||||
|
||||
Helper method to construct manifest URLs with time parameters
|
||||
in various formats that different providers use.
|
||||
|
||||
Args:
|
||||
base_url: Base manifest URL
|
||||
start_time: Start time as Unix timestamp
|
||||
end_time: End time as Unix timestamp
|
||||
url_format: Format ('query', 'path', 'fragment')
|
||||
|
||||
Returns:
|
||||
Complete manifest URL with time parameters
|
||||
|
||||
Override in subclass for provider-specific URL construction.
|
||||
"""
|
||||
if url_format == 'query':
|
||||
# Add as query parameters
|
||||
separator = '&' if '?' in base_url else '?'
|
||||
return f"{base_url}{separator}start={start_time}&end={end_time}"
|
||||
elif url_format == 'path':
|
||||
# Add to path (e.g., /manifest/start/end.mpd)
|
||||
return f"{base_url}/{start_time}/{end_time}"
|
||||
elif url_format == 'fragment':
|
||||
# Add as URL fragment (e.g., manifest.mpd#t=start,end)
|
||||
return f"{base_url}#t={start_time},{end_time}"
|
||||
else:
|
||||
# Default to query parameters
|
||||
separator = '&' if '?' in base_url else '?'
|
||||
return f"{base_url}{separator}start={start_time}&end={end_time}"
|
||||
+215
-36
@@ -361,6 +361,81 @@ class UltimateService:
|
||||
|
||||
return m3u_content
|
||||
|
||||
def _get_proxied_catchup_manifest(self, provider: str, channel_id: str,
|
||||
start_time: int, end_time: int,
|
||||
epg_id: str = None, country: str = None) -> str:
|
||||
"""
|
||||
Get proxied and rewritten MPD manifest for catchup content.
|
||||
Similar to _get_proxied_manifest but for catchup streams.
|
||||
"""
|
||||
# Generate cache key that includes time parameters
|
||||
cache_key = f"{channel_id}_catchup_{start_time}_{end_time}"
|
||||
|
||||
# Try cache first (with catchup-specific key)
|
||||
cached_mpd = self.mpd_cache.get(provider, cache_key)
|
||||
if cached_mpd:
|
||||
response.content_type = 'application/dash+xml; charset=utf-8'
|
||||
return cached_mpd
|
||||
|
||||
logger.info(f"Cache miss for catchup {provider}/{channel_id}, fetching manifest")
|
||||
|
||||
# Get catchup manifest URL
|
||||
manifest_url = self.manager.get_catchup_manifest(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
epg_id=epg_id,
|
||||
country=country
|
||||
)
|
||||
|
||||
if not manifest_url:
|
||||
response.status = 404
|
||||
response.content_type = 'application/json'
|
||||
return json.dumps({'error': f'Catchup manifest not available'})
|
||||
|
||||
# Get provider's HTTP manager
|
||||
http_manager = self.manager.get_provider_http_manager(provider)
|
||||
if not http_manager:
|
||||
logger.error(f"No HTTP manager found for provider '{provider}'")
|
||||
response.status = 502
|
||||
response.content_type = 'application/json'
|
||||
return json.dumps({'error': f'Provider "{provider}" not configured properly'})
|
||||
|
||||
# Fetch manifest via proxy
|
||||
try:
|
||||
logger.debug(f"Fetching catchup manifest via proxy: {manifest_url}")
|
||||
manifest_response = http_manager.get(manifest_url, operation="manifest")
|
||||
|
||||
# Extract cache TTL
|
||||
ttl = MPDRewriter.extract_cache_ttl(manifest_response.headers)
|
||||
mpd_ttl = MPDRewriter.extract_mpd_update_period(manifest_response.text)
|
||||
if mpd_ttl and mpd_ttl < ttl:
|
||||
ttl = mpd_ttl
|
||||
|
||||
# Rewrite MPD URLs to point to proxy
|
||||
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
|
||||
rewriter = MPDRewriter(base_url, provider)
|
||||
rewritten_mpd = rewriter.rewrite_mpd(manifest_response.text, manifest_url)
|
||||
|
||||
# Cache the rewritten MPD with catchup-specific key
|
||||
self.mpd_cache.set(
|
||||
provider=provider,
|
||||
channel_id=cache_key, # Use catchup-specific cache key
|
||||
mpd_content=rewritten_mpd,
|
||||
ttl=ttl,
|
||||
original_url=manifest_url
|
||||
)
|
||||
|
||||
response.content_type = 'application/dash+xml; charset=utf-8'
|
||||
return rewritten_mpd
|
||||
|
||||
except Exception as fetch_err:
|
||||
logger.error(f"Failed to fetch catchup manifest via proxy: {fetch_err}")
|
||||
response.status = 502
|
||||
response.content_type = 'application/json'
|
||||
return json.dumps({'error': f'Failed to fetch manifest: {str(fetch_err)}'})
|
||||
|
||||
def setup_routes(self):
|
||||
@self.app.route('/api/providers')
|
||||
def list_providers():
|
||||
@@ -401,11 +476,29 @@ class UltimateService:
|
||||
fetch_manifests=request.query.get('fetch_manifests', 'false').lower() == 'true',
|
||||
country=request.query.get('country')
|
||||
)
|
||||
|
||||
# Get provider instance to check catchup support
|
||||
provider_instance = self.manager.get_provider(provider)
|
||||
provider_catchup_days = getattr(provider_instance, 'catchup_window', 0)
|
||||
|
||||
# Build channel list with catchup info
|
||||
channels_data = []
|
||||
for c in channels:
|
||||
channel_dict = c.to_dict()
|
||||
|
||||
# Add catchup days - use channel-specific if available, else provider default
|
||||
if hasattr(c, 'catchup_days'):
|
||||
channel_dict['CatchupDays'] = c.catchup_days
|
||||
else:
|
||||
channel_dict['CatchupDays'] = provider_catchup_days
|
||||
|
||||
channels_data.append(channel_dict)
|
||||
|
||||
return {
|
||||
'provider': provider,
|
||||
'country': self.manager.get_provider(provider).country if self.manager.get_provider(
|
||||
provider) else 'DE',
|
||||
'channels': [c.to_dict() for c in channels]
|
||||
'country': provider_instance.country if provider_instance else 'DE',
|
||||
'catchup_window_days': provider_catchup_days,
|
||||
'channels': channels_data
|
||||
}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}: {str(api_err)}")
|
||||
@@ -446,39 +539,96 @@ class UltimateService:
|
||||
def get_channel_stream(provider, channel_id):
|
||||
"""
|
||||
Returns HTTP 302 redirect to the actual manifest or rewritten manifest endpoint.
|
||||
This allows players to use this endpoint directly as a stream URL.
|
||||
Supports both live and catchup streaming.
|
||||
"""
|
||||
try:
|
||||
# Check if provider needs proxy
|
||||
if self.manager.needs_proxy(provider):
|
||||
# Proxy mode: return rewritten MPD content directly
|
||||
# (not redirect, because /manifest now returns JSON)
|
||||
return self._get_proxied_manifest(provider, channel_id)
|
||||
# Get optional catchup parameters
|
||||
start_time = request.query.get('start_time')
|
||||
end_time = request.query.get('end_time')
|
||||
epg_id = request.query.get('epg_id')
|
||||
country = request.query.get('country')
|
||||
|
||||
# Determine if this is a catchup request
|
||||
is_catchup = bool(start_time and end_time)
|
||||
|
||||
if is_catchup:
|
||||
logger.info(f"Catchup stream request for {provider}/{channel_id}: "
|
||||
f"start={start_time}, end={end_time}, epg_id={epg_id}")
|
||||
|
||||
# Convert Unix timestamps to integers
|
||||
try:
|
||||
start_time_int = int(start_time)
|
||||
end_time_int = int(end_time)
|
||||
except (ValueError, TypeError):
|
||||
response.status = 400
|
||||
return {'error': 'Invalid start_time or end_time format'}
|
||||
|
||||
# Validate catchup is supported
|
||||
provider_instance = self.manager.get_provider(provider)
|
||||
catchup_window = getattr(provider_instance, 'catchup_window', 0)
|
||||
|
||||
if catchup_window == 0:
|
||||
response.status = 400
|
||||
return {'error': f'Catchup not supported for provider "{provider}"'}
|
||||
|
||||
# Validate time is within catchup window
|
||||
import time
|
||||
now = int(time.time())
|
||||
max_age_seconds = catchup_window * 86400
|
||||
|
||||
if (now - start_time_int) > max_age_seconds:
|
||||
response.status = 400
|
||||
return {'error': f'Content outside catchup window (max {catchup_window} days)'}
|
||||
|
||||
# Check if provider needs proxy for catchup
|
||||
if self.manager.needs_proxy(provider):
|
||||
# Proxy mode: return rewritten MPD content directly
|
||||
return self._get_proxied_catchup_manifest(
|
||||
provider, channel_id, start_time_int, end_time_int, epg_id, country
|
||||
)
|
||||
else:
|
||||
# Direct mode: get catchup manifest URL and redirect
|
||||
manifest_url = self.manager.get_catchup_manifest(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
start_time=start_time_int,
|
||||
end_time=end_time_int,
|
||||
epg_id=epg_id,
|
||||
country=country
|
||||
)
|
||||
|
||||
if not manifest_url:
|
||||
response.status = 404
|
||||
return {'error': f'Catchup manifest not available for channel "{channel_id}"'}
|
||||
|
||||
logger.debug(f"Redirecting to catchup manifest: {manifest_url}")
|
||||
redirect(manifest_url)
|
||||
else:
|
||||
# Direct mode: redirect to original manifest URL
|
||||
manifest_url = self.manager.get_channel_manifest(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
country=request.query.get('country')
|
||||
)
|
||||
# Live stream - existing logic
|
||||
if self.manager.needs_proxy(provider):
|
||||
return self._get_proxied_manifest(provider, channel_id)
|
||||
else:
|
||||
manifest_url = self.manager.get_channel_manifest(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
country=country
|
||||
)
|
||||
|
||||
if not manifest_url:
|
||||
response.status = 404
|
||||
return {
|
||||
'error': f'Manifest not available for channel "{channel_id}" from provider "{provider}"'}
|
||||
if not manifest_url:
|
||||
response.status = 404
|
||||
return {'error': f'Manifest not available for channel "{channel_id}"'}
|
||||
|
||||
logger.debug(f"Redirecting to manifest: {manifest_url}")
|
||||
redirect(manifest_url)
|
||||
logger.debug(f"Redirecting to manifest: {manifest_url}")
|
||||
redirect(manifest_url)
|
||||
|
||||
except HTTPResponse:
|
||||
# Re-raise HTTPResponse - this is how Bottle handles redirects
|
||||
raise
|
||||
except ValueError as val_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/stream: {str(val_err)}")
|
||||
logger.error(f"API Error in stream: {str(val_err)}")
|
||||
response.status = 404
|
||||
return {'error': str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/stream: {str(api_err)}")
|
||||
logger.error(f"API Error in stream: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': f'Internal server error: {str(api_err)}'}
|
||||
|
||||
@@ -722,11 +872,43 @@ class UltimateService:
|
||||
@self.app.route('/api/providers/<provider>/channels/<channel_id>/drm')
|
||||
def get_channel_drm(provider, channel_id):
|
||||
try:
|
||||
drm_configs = self.manager.get_channel_drm_configs(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
country=request.query.get('country')
|
||||
)
|
||||
# Get optional catchup parameters
|
||||
start_time = request.query.get('start_time')
|
||||
end_time = request.query.get('end_time')
|
||||
epg_id = request.query.get('epg_id')
|
||||
country = request.query.get('country')
|
||||
|
||||
# Determine if this is a catchup request
|
||||
is_catchup = bool(start_time and end_time)
|
||||
|
||||
if is_catchup:
|
||||
logger.debug(f"Catchup DRM request for {provider}/{channel_id}: "
|
||||
f"epg_id={epg_id}")
|
||||
|
||||
# Convert timestamps
|
||||
try:
|
||||
start_time_int = int(start_time)
|
||||
end_time_int = int(end_time)
|
||||
except (ValueError, TypeError):
|
||||
response.status = 400
|
||||
return {'error': 'Invalid start_time or end_time format'}
|
||||
|
||||
# Get catchup DRM configs
|
||||
drm_configs = self.manager.get_catchup_drm_configs(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
start_time=start_time_int,
|
||||
end_time=end_time_int,
|
||||
epg_id=epg_id,
|
||||
country=country
|
||||
)
|
||||
else:
|
||||
# Live DRM - existing logic
|
||||
drm_configs = self.manager.get_channel_drm_configs(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
country=country
|
||||
)
|
||||
|
||||
# Merge all DRM configs into a single dictionary
|
||||
merged_drm_configs = {}
|
||||
@@ -735,24 +917,21 @@ class UltimateService:
|
||||
config_dict = config.to_dict()
|
||||
else:
|
||||
config_dict = config
|
||||
|
||||
# config_dict is something like {"com.widevine.alpha": {...}}
|
||||
# We need to merge it into the main dictionary
|
||||
merged_drm_configs.update(config_dict)
|
||||
|
||||
return {
|
||||
'provider': provider,
|
||||
'channel_id': channel_id,
|
||||
'drm_configs': merged_drm_configs # Now an object, not an array
|
||||
'is_catchup': is_catchup,
|
||||
'drm_configs': merged_drm_configs
|
||||
}
|
||||
|
||||
except ValueError as val_err:
|
||||
# This handles the case where manager raises ValueError for unknown provider
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/drm: {str(val_err)}")
|
||||
logger.error(f"API Error in DRM endpoint: {str(val_err)}")
|
||||
response.status = 404
|
||||
return {'error': str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/drm: {str(api_err)}")
|
||||
logger.error(f"API Error in DRM endpoint: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': f'Internal server error: {str(api_err)}'}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user