mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-16 14:12:20 +02:00
884 lines
37 KiB
Python
884 lines
37 KiB
Python
#!/usr/bin/env python3
|
|
import os
|
|
import sys
|
|
import threading
|
|
from datetime import datetime
|
|
import xbmc
|
|
import xbmcaddon
|
|
import json
|
|
from bottle import Bottle, run, request, response, redirect, HTTPResponse
|
|
from urllib.parse import urlencode, parse_qsl
|
|
|
|
# Get addon settings
|
|
ADDON = xbmcaddon.Addon()
|
|
ADDON_PATH = ADDON.getAddonInfo('path')
|
|
LIB_PATH = os.path.join(ADDON_PATH, 'lib')
|
|
sys.path.insert(0, LIB_PATH)
|
|
|
|
try:
|
|
from streaming_providers import get_configured_manager
|
|
from streaming_providers.base.models import StreamingChannel
|
|
from streaming_providers.base.utils import logger, VFS, MPDRewriter, MPDCacheManager
|
|
except ImportError as import_err:
|
|
xbmc.log(f"Ultimate Backend: Critical import failed - {str(import_err)}", xbmc.LOGERROR)
|
|
raise
|
|
|
|
|
|
class UltimateService:
|
|
def __init__(self):
|
|
self.app = Bottle()
|
|
try:
|
|
self.manager = get_configured_manager()
|
|
logger.info("Manager initialized successfully")
|
|
except Exception as init_err:
|
|
logger.error(f"Failed to initialize manager - {str(init_err)}")
|
|
raise
|
|
|
|
# Initialize VFS for M3U caching
|
|
self.vfs = VFS(addon_subdir="m3u_cache")
|
|
logger.info(f"VFS initialized for M3U caching: {self.vfs.base_path}")
|
|
|
|
self.mpd_cache = MPDCacheManager()
|
|
logger.info(f"MPD cache initialized: {self.mpd_cache.vfs.base_path}")
|
|
|
|
self.setup_routes()
|
|
|
|
def _get_proxied_manifest(self, provider: str, channel_id: str) -> str:
|
|
"""
|
|
Get proxied and rewritten MPD manifest for a channel.
|
|
Uses cache when available and valid.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
channel_id: Channel ID
|
|
|
|
Returns:
|
|
Rewritten MPD content as string
|
|
"""
|
|
country = request.query.get('country')
|
|
|
|
# Try cache first
|
|
cached_mpd = self.mpd_cache.get(provider, channel_id)
|
|
if cached_mpd:
|
|
response.content_type = 'application/dash+xml; charset=utf-8'
|
|
return cached_mpd
|
|
|
|
# Cache miss - fetch and rewrite
|
|
logger.info(f"Cache miss for {provider}/{channel_id}, fetching manifest")
|
|
|
|
# Get original manifest URL
|
|
manifest_url = self.manager.get_channel_manifest(
|
|
provider_name=provider,
|
|
channel_id=channel_id,
|
|
country=country
|
|
)
|
|
|
|
if not manifest_url:
|
|
response.status = 404
|
|
response.content_type = 'application/json'
|
|
return json.dumps(
|
|
{'error': f'Manifest not available for channel "{channel_id}" from provider "{provider}"'})
|
|
|
|
# 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 manifest via proxy: {manifest_url}")
|
|
manifest_response = http_manager.get(manifest_url, operation="manifest")
|
|
|
|
# Extract cache TTL from response headers
|
|
ttl = MPDRewriter.extract_cache_ttl(manifest_response.headers)
|
|
|
|
# Also check MPD's own update period as fallback
|
|
mpd_ttl = MPDRewriter.extract_mpd_update_period(manifest_response.text)
|
|
if mpd_ttl and mpd_ttl < ttl:
|
|
ttl = mpd_ttl
|
|
logger.debug(f"Using MPD minimumUpdatePeriod as TTL: {ttl}s")
|
|
|
|
# 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
|
|
self.mpd_cache.set(
|
|
provider=provider,
|
|
channel_id=channel_id,
|
|
mpd_content=rewritten_mpd,
|
|
ttl=ttl,
|
|
original_url=manifest_url
|
|
)
|
|
|
|
# Return rewritten MPD
|
|
response.content_type = 'application/dash+xml; charset=utf-8'
|
|
return rewritten_mpd
|
|
|
|
except Exception as fetch_err:
|
|
logger.error(f"Failed to fetch manifest via proxy: {fetch_err}")
|
|
response.status = 502
|
|
response.content_type = 'application/json'
|
|
return json.dumps({'error': f'Failed to fetch manifest via proxy: {str(fetch_err)}'})
|
|
|
|
def _generate_m3u_content(self, providers=None, save_to_cache=True, cache_filename=None):
|
|
"""
|
|
Internal method to generate M3U content for specified providers.
|
|
|
|
Args:
|
|
providers: List of provider names, or None for all providers
|
|
save_to_cache: Whether to save to cache
|
|
cache_filename: Cache filename to use
|
|
|
|
Returns:
|
|
M3U content as string
|
|
"""
|
|
# Get base URL for absolute stream URLs
|
|
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
|
|
|
|
# Start M3U content
|
|
m3u_content = "#EXTM3U\n"
|
|
|
|
# Determine which providers to process
|
|
if providers is None:
|
|
# All providers
|
|
providers_to_process = self.manager.list_providers()
|
|
cache_filename = cache_filename or "playlist.m3u"
|
|
else:
|
|
# Specific provider(s)
|
|
providers_to_process = [providers] if isinstance(providers, str) else providers
|
|
cache_filename = cache_filename or f"{providers_to_process[0]}.m3u"
|
|
|
|
for provider_name in providers_to_process:
|
|
try:
|
|
# Get channels for this provider
|
|
channels = self.manager.get_channels(
|
|
provider_name=provider_name,
|
|
fetch_manifests=False
|
|
)
|
|
|
|
# Add each channel to M3U
|
|
for channel in channels:
|
|
m3u_content += self._generate_m3u_channel_entry(base_url, provider_name, channel)
|
|
|
|
except Exception as provider_err:
|
|
logger.warning(f"Failed to get channels for provider '{provider_name}': {str(provider_err)}")
|
|
continue
|
|
|
|
# Save to cache if requested
|
|
if save_to_cache and cache_filename:
|
|
if self.vfs.write_text(cache_filename, m3u_content):
|
|
logger.info(f"M3U playlist cached to {cache_filename}")
|
|
else:
|
|
logger.warning(f"Failed to cache M3U playlist to {cache_filename}")
|
|
|
|
return m3u_content
|
|
|
|
def _generate_m3u_channel_entry(self, base_url, provider_name, channel):
|
|
"""
|
|
Generate M3U entry for a single channel.
|
|
|
|
Args:
|
|
base_url: Base URL for stream endpoints
|
|
provider_name: Name of the provider
|
|
channel: StreamingChannel object
|
|
|
|
Returns:
|
|
M3U entry as string
|
|
"""
|
|
entry_content = ""
|
|
|
|
# Access StreamingChannel attributes directly
|
|
channel_id = channel.channel_id
|
|
channel_name = channel.name
|
|
channel_logo = channel.logo_url or ''
|
|
|
|
# Build stream URL
|
|
stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream"
|
|
|
|
# Get provider instance to access provider_label
|
|
try:
|
|
provider_instance = self.manager.get_provider(provider_name)
|
|
provider_label = provider_instance.provider_label
|
|
except (AttributeError, KeyError, ValueError):
|
|
# Fallback to provider_name if provider_label is not available
|
|
provider_label = provider_name
|
|
|
|
# Add M3U entry with extended info first
|
|
entry_content += f'#EXTINF:-1 tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
|
|
|
|
# Get DRM configs and add KODIPROP directives
|
|
try:
|
|
drm_configs = self.manager.get_channel_drm_configs(
|
|
provider_name=provider_name,
|
|
channel_id=channel_id
|
|
)
|
|
|
|
if drm_configs:
|
|
drm_directives = self._generate_drm_directives(drm_configs)
|
|
entry_content += drm_directives
|
|
|
|
except Exception as drm_err:
|
|
logger.debug(f"Could not get DRM for {provider_name}/{channel_id}: {str(drm_err)}")
|
|
|
|
# Add stream URL
|
|
entry_content += f'{stream_url}\n'
|
|
|
|
return entry_content
|
|
|
|
def _generate_drm_directives(self, drm_configs):
|
|
"""
|
|
Generate KODIPROP directives for DRM configuration.
|
|
|
|
Args:
|
|
drm_configs: List of DRM configurations
|
|
|
|
Returns:
|
|
DRM directives as string
|
|
"""
|
|
directives = ""
|
|
|
|
# Prioritize: clearkey > widevine > playready
|
|
selected_drm = None
|
|
priority_order = ['org.w3.clearkey', 'com.widevine.alpha', 'com.microsoft.playready']
|
|
|
|
for priority_system in priority_order:
|
|
for drm in drm_configs:
|
|
drm_dict = drm.to_dict() if hasattr(drm, 'to_dict') else drm
|
|
if priority_system in drm_dict:
|
|
selected_drm = (priority_system, drm_dict[priority_system])
|
|
break
|
|
if selected_drm:
|
|
break
|
|
|
|
if selected_drm:
|
|
drm_system, drm_data = selected_drm
|
|
|
|
# Add KODIPROP directives
|
|
directives += "#KODIPROP:inputstream=inputstream.adaptive\n"
|
|
|
|
# Build DRM legacy string
|
|
drm_legacy_parts = [drm_system]
|
|
|
|
license_info = drm_data.get('license', {})
|
|
|
|
# Add license server URL or keyids
|
|
if drm_system == 'org.w3.clearkey' and license_info.get('keyids'):
|
|
# ClearKey: format as kid:key,kid:key
|
|
keyids = license_info['keyids']
|
|
keys_str = ','.join([f"{kid}:{key}" for kid, key in keyids.items()])
|
|
drm_legacy_parts.append(keys_str)
|
|
elif license_info.get('server_url'):
|
|
# Widevine/PlayReady: add license server URL
|
|
drm_legacy_parts.append(license_info['server_url'])
|
|
|
|
# Add headers if present (URL-encoded)
|
|
if license_info.get('req_headers'):
|
|
req_headers = self._process_license_headers(license_info['req_headers'])
|
|
if req_headers:
|
|
drm_legacy_parts.append(req_headers)
|
|
|
|
# Join parts with pipe separator
|
|
drm_legacy = '|'.join(drm_legacy_parts)
|
|
directives += f"#KODIPROP:inputstream.adaptive.drm_legacy={drm_legacy}\n"
|
|
|
|
return directives
|
|
|
|
def _process_license_headers(self, req_headers):
|
|
"""
|
|
Process license headers and convert to URL-encoded format.
|
|
|
|
Args:
|
|
req_headers: Headers in various formats (dict, JSON string, query string)
|
|
|
|
Returns:
|
|
URL-encoded headers string
|
|
"""
|
|
if isinstance(req_headers, str):
|
|
# Check if it's JSON format
|
|
if req_headers.strip().startswith('{'):
|
|
try:
|
|
# Parse JSON and convert to URL-encoded
|
|
headers_dict = json.loads(req_headers)
|
|
return urlencode(headers_dict)
|
|
except json.JSONDecodeError:
|
|
# If not JSON, try to parse as query string
|
|
try:
|
|
headers_dict = dict(parse_qsl(req_headers))
|
|
return urlencode(headers_dict)
|
|
except:
|
|
logger.warning(f"Failed to parse headers: {req_headers}")
|
|
return req_headers
|
|
else:
|
|
# Assume it's already URL-encoded or query string format
|
|
try:
|
|
headers_dict = dict(parse_qsl(req_headers))
|
|
return urlencode(headers_dict)
|
|
except:
|
|
return req_headers
|
|
elif isinstance(req_headers, dict):
|
|
# Convert dict to URL-encoded string
|
|
return urlencode(req_headers)
|
|
else:
|
|
logger.warning(f"Unsupported headers type: {type(req_headers)}")
|
|
return str(req_headers)
|
|
|
|
def _generate_m3u_all(self, save_to_cache: bool = False) -> str:
|
|
"""Internal method to generate M3U for all providers."""
|
|
logger.info("Generating M3U playlist for all providers")
|
|
m3u_content = self._generate_m3u_content(providers=None, save_to_cache=save_to_cache)
|
|
|
|
# Set appropriate headers for M3U
|
|
response.content_type = 'audio/x-mpegurl; charset=utf-8'
|
|
response.headers['Content-Disposition'] = 'attachment; filename="playlist.m3u8"'
|
|
|
|
return m3u_content
|
|
|
|
def _generate_m3u_provider(self, provider: str, save_to_cache: bool = False) -> str:
|
|
"""Internal method to generate M3U for a specific provider."""
|
|
logger.info(f"Generating M3U playlist for provider '{provider}'")
|
|
m3u_content = self._generate_m3u_content(providers=provider, save_to_cache=save_to_cache)
|
|
|
|
# Set appropriate headers for M3U
|
|
response.content_type = 'audio/x-mpegurl; charset=utf-8'
|
|
response.headers['Content-Disposition'] = f'attachment; filename="{provider}_playlist.m3u8"'
|
|
|
|
return m3u_content
|
|
|
|
def setup_routes(self):
|
|
@self.app.route('/api/providers')
|
|
def list_providers():
|
|
try:
|
|
providers = self.manager.list_providers()
|
|
default_country = ADDON.getSetting('default_country') or 'DE'
|
|
return {
|
|
'providers': providers,
|
|
'default_country': default_country
|
|
}
|
|
except Exception as api_err:
|
|
logger.error(f"API Error in /api/providers: {str(api_err)}")
|
|
response.status = 500
|
|
return {'error': str(api_err)}
|
|
|
|
@self.app.route('/api/providers/<provider>/channels')
|
|
def get_channels(provider):
|
|
try:
|
|
channels = self.manager.get_channels(
|
|
provider_name=provider,
|
|
fetch_manifests=request.query.get('fetch_manifests', 'false').lower() == 'true',
|
|
country=request.query.get('country')
|
|
)
|
|
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]
|
|
}
|
|
except Exception as api_err:
|
|
logger.error(f"API Error in /api/providers/{provider}: {str(api_err)}")
|
|
response.status = 500
|
|
return {'error': str(api_err)}
|
|
|
|
@self.app.route('/api/providers/<provider>/channels/<channel_id>/manifest')
|
|
def get_channel_manifest(provider, channel_id):
|
|
"""
|
|
Get channel manifest. If provider uses proxy, returns rewritten MPD content.
|
|
Otherwise returns manifest URL as JSON.
|
|
"""
|
|
try:
|
|
# Check if provider needs proxy
|
|
if self.manager.needs_proxy(provider):
|
|
# Proxy mode: return rewritten MPD content
|
|
return self._get_proxied_manifest(provider, channel_id)
|
|
else:
|
|
# Direct mode: return manifest URL as JSON (existing behavior)
|
|
manifest_url = self.manager.get_channel_manifest(
|
|
provider_name=provider,
|
|
channel_id=channel_id,
|
|
country=request.query.get('country')
|
|
)
|
|
|
|
if not manifest_url:
|
|
response.status = 404
|
|
return {'error': f'Manifest not available for channel "{channel_id}" from provider "{provider}"'}
|
|
|
|
return {
|
|
'provider': provider,
|
|
'channel_id': channel_id,
|
|
'manifest_url': manifest_url
|
|
}
|
|
|
|
except ValueError as val_err:
|
|
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/manifest: {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}/manifest: {str(api_err)}")
|
|
response.status = 500
|
|
return {'error': f'Internal server error: {str(api_err)}'}
|
|
|
|
@self.app.route('/api/providers/<provider>/channels/<channel_id>/stream')
|
|
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.
|
|
"""
|
|
try:
|
|
# Check if provider needs proxy
|
|
if self.manager.needs_proxy(provider):
|
|
# Proxy mode: redirect to our manifest endpoint which serves rewritten MPD
|
|
country = request.query.get('country')
|
|
manifest_endpoint = f"/api/providers/{provider}/channels/{channel_id}/manifest"
|
|
if country:
|
|
manifest_endpoint += f"?country={country}"
|
|
|
|
logger.debug(f"Redirecting to proxied manifest endpoint: {manifest_endpoint}")
|
|
redirect(manifest_endpoint)
|
|
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')
|
|
)
|
|
|
|
if not manifest_url:
|
|
response.status = 404
|
|
return {
|
|
'error': f'Manifest not available for channel "{channel_id}" from provider "{provider}"'}
|
|
|
|
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)}")
|
|
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)}")
|
|
response.status = 500
|
|
return {'error': f'Internal server error: {str(api_err)}'}
|
|
|
|
@self.app.route('/api/proxy/<provider>/<encoded_url:path>')
|
|
def proxy_media_segment(provider, encoded_url):
|
|
"""
|
|
Proxy media segments through provider's HTTP manager.
|
|
Decodes base64 URL and appends any template suffix, then fetches via configured proxy.
|
|
|
|
URL format: /api/proxy/<provider>/<base64_encoded_base_url>/<optional_template_path>
|
|
Example: /api/proxy/joyn_ch/aHR0cHM6Ly9jZG4uZXhhbXBsZS5jb20vcGF0aA==/segment-123.m4s
|
|
"""
|
|
try:
|
|
# Split the encoded_url into base64 part and optional suffix
|
|
# The first path segment is the base64-encoded base URL
|
|
# Everything after that is the template path (already resolved by client)
|
|
parts = encoded_url.split('/', 1)
|
|
base64_part = parts[0]
|
|
template_suffix = parts[1] if len(parts) > 1 else ''
|
|
|
|
# Decode the base URL
|
|
try:
|
|
base_url = MPDRewriter.decode_url(base64_part)
|
|
except Exception as decode_err:
|
|
logger.error(f"Failed to decode proxy URL: {decode_err}")
|
|
response.status = 400
|
|
return {'error': 'Invalid encoded URL'}
|
|
|
|
# Reconstruct the full URL
|
|
# If there's a template suffix, append it to the base URL
|
|
if template_suffix:
|
|
# Ensure proper joining (base_url might or might not end with /)
|
|
if base_url.endswith('/'):
|
|
original_url = base_url + template_suffix
|
|
else:
|
|
original_url = base_url + '/' + template_suffix
|
|
else:
|
|
original_url = base_url
|
|
|
|
logger.debug(f"Proxy request for {provider}:")
|
|
logger.debug(f" Base64 part: {base64_part[:50]}...")
|
|
logger.debug(f" Decoded base: {base_url}")
|
|
logger.debug(f" Template suffix: {template_suffix}")
|
|
logger.debug(f" Final URL: {original_url}")
|
|
|
|
# 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 = 404
|
|
return {'error': f'Provider "{provider}" not found or not configured'}
|
|
|
|
# Check if proxy is configured
|
|
if not http_manager.config.proxy_config:
|
|
logger.error(f"Provider '{provider}' has no proxy configured")
|
|
response.status = 502
|
|
return {'error': f'Provider "{provider}" has no proxy configured'}
|
|
|
|
logger.info(f"Fetching media segment via proxy for {provider}: {original_url[:100]}...")
|
|
|
|
# Fetch via proxy using 'manifest' operation
|
|
proxy_response = http_manager.get(original_url, operation="manifest")
|
|
|
|
logger.info(f"Successfully fetched segment, size: {len(proxy_response.content)} bytes")
|
|
|
|
# Set response headers from proxied response
|
|
response.content_type = proxy_response.headers.get('Content-Type', 'application/octet-stream')
|
|
|
|
# Add Content-Length if available
|
|
if 'Content-Length' in proxy_response.headers:
|
|
response.headers['Content-Length'] = proxy_response.headers['Content-Length']
|
|
|
|
# Copy other potentially useful headers
|
|
for header in ['Cache-Control', 'ETag', 'Last-Modified']:
|
|
if header in proxy_response.headers:
|
|
response.headers[header] = proxy_response.headers[header]
|
|
|
|
# Return the content directly
|
|
return proxy_response.content
|
|
|
|
except Exception as proxy_err:
|
|
logger.error(f"Proxy error for {provider}: {str(proxy_err)}", exc_info=True)
|
|
response.status = 502
|
|
return {'error': f'Proxy failed: {str(proxy_err)}'}
|
|
|
|
@self.app.route('/api/providers/<provider>/channels/<channel_id>/pssh')
|
|
def get_channel_pssh(provider, channel_id):
|
|
try:
|
|
# Get the manifest URL first
|
|
manifest_url = self.manager.get_channel_manifest(
|
|
provider_name=provider,
|
|
channel_id=channel_id,
|
|
country=request.query.get('country')
|
|
)
|
|
|
|
if not manifest_url:
|
|
response.status = 404
|
|
return {'error': f'Manifest not available for channel "{channel_id}" from provider "{provider}"'}
|
|
|
|
# Extract PSSH data from the manifest
|
|
pssh_data_list = self.manager.extract_pssh_from_manifest(manifest_url)
|
|
|
|
if not pssh_data_list:
|
|
response.status = 404
|
|
return {
|
|
'error': f'No PSSH data found in manifest for channel "{channel_id}" from provider "{provider}"'}
|
|
|
|
# Convert PSSH data to dictionary format for JSON response
|
|
pssh_list = []
|
|
for pssh_data in pssh_data_list:
|
|
if hasattr(pssh_data, 'to_dict'):
|
|
pssh_list.append(pssh_data.to_dict())
|
|
else:
|
|
# Fallback for basic PSSH data structure
|
|
pssh_dict = {
|
|
'pssh': getattr(pssh_data, 'pssh', str(pssh_data)) if hasattr(pssh_data, 'pssh') else str(
|
|
pssh_data),
|
|
'system_id': getattr(pssh_data, 'system_id', None),
|
|
'key_id': getattr(pssh_data, 'key_id', None) if hasattr(pssh_data, 'key_id') else None
|
|
}
|
|
# Remove None values
|
|
pssh_dict = {k: v for k, v in pssh_dict.items() if v is not None}
|
|
pssh_list.append(pssh_dict)
|
|
|
|
return {
|
|
'provider': provider,
|
|
'channel_id': channel_id,
|
|
'manifest_url': manifest_url,
|
|
'pssh_data': pssh_list,
|
|
'count': len(pssh_list)
|
|
}
|
|
|
|
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}/pssh: {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}/pssh: {str(api_err)}")
|
|
response.status = 500
|
|
return {'error': f'Internal server error: {str(api_err)}'}
|
|
|
|
@self.app.route('/api/providers/<provider>/channels/<channel_id>/epg')
|
|
def get_channel_epg(provider, channel_id):
|
|
try:
|
|
# Parse optional datetime parameters
|
|
kwargs = {'country': request.query.get('country')}
|
|
|
|
if request.query.get('start_time'):
|
|
try:
|
|
kwargs['start_time'] = datetime.fromisoformat(
|
|
request.query.get('start_time').replace('Z', '+00:00'))
|
|
except ValueError:
|
|
response.status = 400
|
|
return {'error': 'Invalid start_time format. Use ISO format (YYYY-MM-DDTHH:MM:SS)'}
|
|
|
|
if request.query.get('end_time'):
|
|
try:
|
|
kwargs['end_time'] = datetime.fromisoformat(
|
|
request.query.get('end_time').replace('Z', '+00:00'))
|
|
except ValueError:
|
|
response.status = 400
|
|
return {'error': 'Invalid end_time format. Use ISO format (YYYY-MM-DDTHH:MM:SS)'}
|
|
|
|
epg_data = self.manager.get_channel_epg(
|
|
provider_name=provider,
|
|
channel_id=channel_id,
|
|
**kwargs
|
|
)
|
|
|
|
return {
|
|
'provider': provider,
|
|
'channel_id': channel_id,
|
|
'epg': epg_data
|
|
}
|
|
|
|
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}/epg: {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}/epg: {str(api_err)}")
|
|
response.status = 500
|
|
return {'error': f'Internal server error: {str(api_err)}'}
|
|
|
|
@self.app.route('/api/providers/<provider>/epg')
|
|
def get_provider_epg_xmltv(provider):
|
|
try:
|
|
# Set appropriate headers for XMLTV
|
|
response.content_type = 'application/xml; charset=utf-8'
|
|
response.headers['Content-Disposition'] = f'attachment; filename="{provider}_epg.xml"'
|
|
|
|
# Get the XMLTV data from the provider
|
|
xmltv_data = self.manager.get_provider_epg_xmltv(
|
|
provider_name=provider,
|
|
country=request.query.get('country')
|
|
)
|
|
|
|
if not xmltv_data:
|
|
response.status = 404
|
|
return {'error': f'EPG data not available for provider "{provider}"'}
|
|
|
|
return xmltv_data
|
|
|
|
except ValueError as val_err:
|
|
# Handle unknown provider
|
|
logger.error(f"API Error in /api/providers/{provider}/epg: {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}/epg: {str(api_err)}")
|
|
response.status = 500
|
|
return {'error': f'Internal server error: {str(api_err)}'}
|
|
|
|
@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')
|
|
)
|
|
|
|
return {
|
|
'provider': provider,
|
|
'channel_id': channel_id,
|
|
'drm_configs': [config.to_dict() if hasattr(config, 'to_dict') else config for config in
|
|
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)}")
|
|
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)}")
|
|
response.status = 500
|
|
return {'error': f'Internal server error: {str(api_err)}'}
|
|
|
|
@self.app.route('/api/m3u')
|
|
def get_m3u_all():
|
|
"""
|
|
Generates M3U playlist for all configured providers.
|
|
Returns cached version if available, otherwise generates new one.
|
|
|
|
Example: http://localhost:7777/api/m3u
|
|
"""
|
|
try:
|
|
cache_file = "playlist.m3u"
|
|
|
|
# Try to read cached file
|
|
cached_content = self.vfs.read_text(cache_file)
|
|
|
|
if cached_content:
|
|
logger.info("Serving cached M3U playlist for all providers")
|
|
response.content_type = 'audio/x-mpegurl; charset=utf-8'
|
|
response.headers['Content-Disposition'] = 'attachment; filename="playlist.m3u8"'
|
|
return cached_content
|
|
|
|
# Cache doesn't exist or is corrupt, generate new M3U
|
|
logger.info("No valid cache found, generating M3U playlist for all providers")
|
|
return self._generate_m3u_all(save_to_cache=True)
|
|
|
|
except Exception as api_err:
|
|
logger.error(f"API Error in /api/m3u: {str(api_err)}")
|
|
response.status = 500
|
|
return {'error': f'Internal server error: {str(api_err)}'}
|
|
|
|
@self.app.route('/api/m3u/generate')
|
|
def generate_m3u_all():
|
|
"""
|
|
Forces regeneration of M3U playlist for all providers and saves to cache.
|
|
|
|
Example: http://localhost:7777/api/m3u/generate
|
|
"""
|
|
try:
|
|
logger.info("Force generating M3U playlist for all providers")
|
|
return self._generate_m3u_all(save_to_cache=True)
|
|
|
|
except Exception as api_err:
|
|
logger.error(f"API Error in /api/m3u/generate: {str(api_err)}")
|
|
response.status = 500
|
|
return {'error': f'Internal server error: {str(api_err)}'}
|
|
|
|
@self.app.route('/api/providers/<provider>/m3u')
|
|
def get_m3u_provider(provider):
|
|
"""
|
|
Generates M3U playlist for a specific provider.
|
|
Returns cached version if available, otherwise generates new one.
|
|
|
|
Example: http://localhost:7777/api/providers/rtlplus/m3u
|
|
"""
|
|
try:
|
|
cache_file = f"{provider}.m3u"
|
|
|
|
# Try to read cached file
|
|
cached_content = self.vfs.read_text(cache_file)
|
|
|
|
if cached_content:
|
|
logger.info(f"Serving cached M3U playlist for provider '{provider}'")
|
|
response.content_type = 'audio/x-mpegurl; charset=utf-8'
|
|
response.headers['Content-Disposition'] = f'attachment; filename="{provider}_playlist.m3u8"'
|
|
return cached_content
|
|
|
|
# Cache doesn't exist or is corrupt, generate new M3U
|
|
logger.info(f"No valid cache found, generating M3U playlist for provider '{provider}'")
|
|
return self._generate_m3u_provider(provider, save_to_cache=True)
|
|
|
|
except ValueError as val_err:
|
|
logger.error(f"API Error in /api/providers/{provider}/m3u: {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}/m3u: {str(api_err)}")
|
|
response.status = 500
|
|
return {'error': f'Internal server error: {str(api_err)}'}
|
|
|
|
@self.app.route('/api/providers/<provider>/m3u/generate')
|
|
def generate_m3u_provider(provider):
|
|
"""
|
|
Forces regeneration of M3U playlist for a specific provider and saves to cache.
|
|
|
|
Example: http://localhost:7777/api/providers/rtlplus/m3u/generate
|
|
"""
|
|
try:
|
|
logger.info(f"Force generating M3U playlist for provider '{provider}'")
|
|
return self._generate_m3u_provider(provider, save_to_cache=True)
|
|
|
|
except ValueError as val_err:
|
|
logger.error(f"API Error in /api/providers/{provider}/m3u/generate: {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}/m3u/generate: {str(api_err)}")
|
|
response.status = 500
|
|
return {'error': f'Internal server error: {str(api_err)}'}
|
|
|
|
@self.app.route('/api/cache/mpd/clear')
|
|
def clear_mpd_cache():
|
|
"""Clear all cached MPD manifests"""
|
|
try:
|
|
self.mpd_cache.clear_all()
|
|
return {'success': True, 'message': 'MPD cache cleared'}
|
|
except Exception as e:
|
|
logger.error(f"Error clearing MPD cache: {e}")
|
|
response.status = 500
|
|
return {'error': str(e)}
|
|
|
|
@self.app.route('/api/cache/mpd/clear-expired')
|
|
def clear_expired_mpd_cache():
|
|
"""Clear expired MPD cache entries"""
|
|
try:
|
|
cleared = self.mpd_cache.clear_expired()
|
|
return {'success': True, 'cleared': cleared}
|
|
except Exception as e:
|
|
logger.error(f"Error clearing expired MPD cache: {e}")
|
|
response.status = 500
|
|
return {'error': str(e)}
|
|
|
|
@self.app.route('/api/cache/mpd/<provider>/<channel_id>')
|
|
def get_mpd_cache_info(provider, channel_id):
|
|
"""Get cache information for a specific channel"""
|
|
try:
|
|
info = self.mpd_cache.get_cache_info(provider, channel_id)
|
|
if info:
|
|
return {'success': True, 'cache_info': info}
|
|
else:
|
|
response.status = 404
|
|
return {'success': False, 'message': 'No cache found'}
|
|
except Exception as e:
|
|
logger.error(f"Error getting cache info: {e}")
|
|
response.status = 500
|
|
return {'error': str(e)}
|
|
|
|
@self.app.route('/api/cache/mpd/<provider>/<channel_id>/delete')
|
|
def delete_mpd_cache(provider, channel_id):
|
|
"""Delete cached MPD for a specific channel"""
|
|
try:
|
|
self.mpd_cache.delete(provider, channel_id)
|
|
return {'success': True, 'message': f'Cache deleted for {provider}/{channel_id}'}
|
|
except Exception as e:
|
|
logger.error(f"Error deleting cache: {e}")
|
|
response.status = 500
|
|
return {'error': str(e)}
|
|
|
|
def run_service():
|
|
service = UltimateService()
|
|
port = int(ADDON.getSetting("server_port") or 7777)
|
|
logger.info(f"Starting server on port {port}")
|
|
run(service.app, host='0.0.0.0', port=port, quiet=True, debug=True)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
logger.info("Starting service...")
|
|
|
|
# Give Kodi time to initialize
|
|
import time
|
|
|
|
time.sleep(5)
|
|
|
|
try:
|
|
service_thread = threading.Thread(
|
|
target=run_service,
|
|
name="UltimateBackendService"
|
|
)
|
|
service_thread.daemon = True
|
|
service_thread.start()
|
|
|
|
monitor = xbmc.Monitor()
|
|
while not monitor.abortRequested():
|
|
if monitor.waitForAbort(5):
|
|
break
|
|
|
|
logger.info("Service stopped")
|
|
except Exception as startup_err:
|
|
logger.error(f"Failed to start - {str(startup_err)}")
|
|
raise |