mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-16 06:02:35 +02:00
Reformat
This commit is contained in:
+16
-9
@@ -47,24 +47,31 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
RUN groupadd -g ${GROUP_ID} ${APP_USER} && \
|
||||
useradd -u ${USER_ID} -g ${APP_USER} -m -s /bin/bash ${APP_USER}
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /config /logs /cache /drm-plugins && \
|
||||
chown -R ${USER_ID}:${GROUP_ID} /config /logs /cache /drm-plugins
|
||||
# Create directories for new structure
|
||||
RUN mkdir -p /config /logs /cache /drm-plugins /app/routes && \
|
||||
chown -R ${USER_ID}:${GROUP_ID} /config /logs /cache /drm-plugins /app/routes
|
||||
|
||||
# Copy application code
|
||||
COPY --chown=${USER_ID}:${GROUP_ID} . .
|
||||
# Copy application code with new structure
|
||||
COPY --chown=${USER_ID}:${GROUP_ID} service.py .
|
||||
# IMPORTANT: Copy the entire routes directory
|
||||
COPY --chown=${USER_ID}:${GROUP_ID} routes/ /app/routes/
|
||||
|
||||
# Create the directory structure for DRM plugins
|
||||
RUN mkdir -p /app/lib/streaming_providers/base/drm/plugins && \
|
||||
chown -R ${USER_ID}:${GROUP_ID} /app/lib/streaming_providers/base/drm
|
||||
|
||||
# Copy entrypoint script
|
||||
# Quick directory check (for debugging)
|
||||
RUN echo "=== Directory structure ===" && \
|
||||
ls -la /app && \
|
||||
echo "---" && \
|
||||
ls -la /app/routes/ 2>/dev/null || echo "routes directory not found" && \
|
||||
echo "---" && \
|
||||
ls -la /app/lib/ 2>/dev/null || echo "lib directory not found"
|
||||
|
||||
# Copy updated entrypoint script
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Quick directory check
|
||||
RUN ls -la /app && echo "---" && ls -la /app/lib/ 2>/dev/null || echo "lib directory not found"
|
||||
|
||||
# Switch to non-root user
|
||||
USER ${USER_ID}
|
||||
|
||||
|
||||
+2
-1
@@ -15,8 +15,9 @@ services:
|
||||
environment:
|
||||
- ULTIMATE_PORT=7777
|
||||
- ULTIMATE_DEBUG=false
|
||||
- ULTIMATE_EPG_URL=https://raw.githubusercontent.com/epgshare01/share01/master/epg.xml.gz
|
||||
- ULTIMATE_EPG_URL=https://example.com/epg.xml.gz
|
||||
- TZ=${TZ:-Europe/Berlin}
|
||||
- MEDIA_PROXY_URL=http://media-proxy:8080 # if using media proxy
|
||||
volumes:
|
||||
- ./config:/config
|
||||
- ./logs:/logs
|
||||
|
||||
@@ -19,5 +19,34 @@ if [ -z "$(ls -A /drm-plugins)" ] && [ -d "/app/lib/streaming_providers/base/drm
|
||||
cp -r /app/lib/streaming_providers/base/drm/default-plugins/* /drm-plugins/
|
||||
fi
|
||||
|
||||
# IMPORTANT: Ensure routes directory exists and has required files
|
||||
# This is needed for the new split structure
|
||||
if [ ! -d "/app/routes" ]; then
|
||||
echo "ERROR: routes directory not found!"
|
||||
echo "The service has been split into modules. Make sure routes/ directory exists in /app/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify all required route files exist
|
||||
REQUIRED_ROUTES=("__init__.py" "providers.py" "streams.py" "m3u.py" "drm.py" "cache.py" "config.py" "epg.py")
|
||||
MISSING_FILES=0
|
||||
|
||||
for route in "${REQUIRED_ROUTES[@]}"; do
|
||||
if [ ! -f "/app/routes/$route" ]; then
|
||||
echo "ERROR: Missing required route file: /app/routes/$route"
|
||||
MISSING_FILES=$((MISSING_FILES + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $MISSING_FILES -gt 0 ]; then
|
||||
echo "ERROR: Missing $MISSING_FILES required route files. Service cannot start."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set Python path to include routes directory
|
||||
export PYTHONPATH="${PYTHONPATH}:/app"
|
||||
|
||||
echo "Routes directory check passed. Starting service..."
|
||||
|
||||
# Execute the main command
|
||||
exec "$@"
|
||||
@@ -2,63 +2,102 @@
|
||||
import base64
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.parse import urljoin, urlparse, quote
|
||||
|
||||
from .logger import logger
|
||||
|
||||
|
||||
class MPDRewriter:
|
||||
"""
|
||||
Utility for rewriting MPD (MPEG-DASH) manifest URLs to point to proxy endpoints
|
||||
Utility for rewriting MPD (MPEG-DASH) manifest URLs to point to media proxy endpoints
|
||||
|
||||
Strategy:
|
||||
- Remove all BaseURL elements
|
||||
- Convert all relative URLs to absolute URLs
|
||||
- Rewrite all absolute URLs to proxy endpoint
|
||||
- Rewrite all absolute URLs to media proxy endpoint
|
||||
- Keep template variables visible for client-side substitution
|
||||
"""
|
||||
|
||||
# MPD namespace
|
||||
MPD_NAMESPACE = {"mpd": "urn:mpeg:dash:schema:mpd:2011"}
|
||||
|
||||
def __init__(self, proxy_base_url: str, provider_name: str):
|
||||
def __init__(self, media_proxy_url: str, provider_proxy_url: Optional[str] = None,
|
||||
clearkey_keyids: Optional[dict] = None):
|
||||
"""
|
||||
Initialize MPD rewriter
|
||||
|
||||
Args:
|
||||
proxy_base_url: Base URL of the proxy service (e.g., http://localhost:7777)
|
||||
provider_name: Name of the provider for proxy routing
|
||||
media_proxy_url: Base URL of the media proxy service (e.g., http://10.77.77.7:7775)
|
||||
provider_proxy_url: Optional proxy URL for the provider (e.g., http://nordlynx_germany:8888)
|
||||
clearkey_keyids: Optional dict of kid:key pairs for decrypted playback
|
||||
"""
|
||||
self.proxy_base_url = proxy_base_url.rstrip("/")
|
||||
self.provider_name = provider_name
|
||||
self.media_proxy_url = media_proxy_url.rstrip("/")
|
||||
self.provider_proxy_url = provider_proxy_url
|
||||
self.clearkey_keyids = clearkey_keyids or {}
|
||||
|
||||
@staticmethod
|
||||
def encode_url(url: str) -> str:
|
||||
"""Encode URL to base64 for use in proxy endpoint"""
|
||||
return base64.urlsafe_b64encode(url.encode("utf-8")).decode("utf-8")
|
||||
"""
|
||||
Encode URL to base64 for use in media proxy endpoint.
|
||||
Strips padding as required by media proxy.
|
||||
"""
|
||||
encoded = base64.urlsafe_b64encode(url.encode("utf-8")).decode("utf-8")
|
||||
# Strip padding
|
||||
return encoded.rstrip("=")
|
||||
|
||||
@staticmethod
|
||||
def decode_url(encoded: str) -> str:
|
||||
"""Decode base64 URL from proxy endpoint"""
|
||||
"""
|
||||
Decode base64 URL from media proxy endpoint.
|
||||
Adds back padding if needed.
|
||||
"""
|
||||
# Add back padding if needed
|
||||
padding = 4 - (len(encoded) % 4)
|
||||
if padding != 4:
|
||||
encoded += "=" * padding
|
||||
return base64.urlsafe_b64decode(encoded.encode("utf-8")).decode("utf-8")
|
||||
|
||||
def build_proxy_url(self, original_url: str, template_pattern: Optional[str] = None) -> str:
|
||||
"""
|
||||
Build proxy URL for an original media URL
|
||||
Build media proxy URL for an original media URL
|
||||
|
||||
Args:
|
||||
original_url: Original URL to be proxied (base path for templates)
|
||||
template_pattern: Optional template pattern to append (e.g., "segment-$Number$.m4s")
|
||||
|
||||
Returns:
|
||||
Proxy URL
|
||||
Media proxy URL
|
||||
"""
|
||||
encoded = self.encode_url(original_url)
|
||||
proxy_url = f"{self.proxy_base_url}/api/proxy/{self.provider_name}/{encoded}"
|
||||
|
||||
# Choose endpoint based on whether we have clearkey data
|
||||
if self.clearkey_keyids:
|
||||
proxy_url = f"{self.media_proxy_url}/api/decrypt/{encoded}"
|
||||
else:
|
||||
proxy_url = f"{self.media_proxy_url}/api/proxy/{encoded}"
|
||||
|
||||
# Append template pattern if provided (keeps variables visible for client)
|
||||
if template_pattern:
|
||||
proxy_url += f"/{template_pattern}"
|
||||
# URL encode the template pattern (same as current behavior)
|
||||
encoded_pattern = quote(template_pattern, safe=".-_$")
|
||||
proxy_url += f"/{encoded_pattern}"
|
||||
|
||||
# Build query parameters
|
||||
query_params = []
|
||||
|
||||
# Add clearkey parameters if present
|
||||
if self.clearkey_keyids:
|
||||
for kid, key in self.clearkey_keyids.items():
|
||||
query_params.append(f"kid={kid}")
|
||||
query_params.append(f"key={key}")
|
||||
|
||||
# Add provider proxy parameter if configured
|
||||
if self.provider_proxy_url:
|
||||
query_params.append(f"proxy={self.provider_proxy_url}")
|
||||
|
||||
# Append query string if we have parameters
|
||||
if query_params:
|
||||
proxy_url += "?" + "&".join(query_params)
|
||||
|
||||
return proxy_url
|
||||
|
||||
@@ -98,13 +137,13 @@ class MPDRewriter:
|
||||
|
||||
def rewrite_mpd(self, mpd_content: str, manifest_url: str) -> str:
|
||||
"""
|
||||
Rewrite MPD content to use proxy URLs
|
||||
Rewrite MPD content to use media proxy URLs
|
||||
|
||||
Strategy:
|
||||
1. Parse MPD XML
|
||||
2. Extract and remove all BaseURL elements
|
||||
3. Resolve all relative URLs to absolute using BaseURLs and manifest URL
|
||||
4. Rewrite all absolute URLs to proxy endpoints
|
||||
4. Rewrite all absolute URLs to media proxy endpoints
|
||||
5. Keep template variables visible for client substitution
|
||||
|
||||
Args:
|
||||
@@ -124,7 +163,7 @@ class MPDRewriter:
|
||||
# Get base URL for relative resolution
|
||||
base_url = self._extract_base_url(root, manifest_url)
|
||||
|
||||
# Remove all BaseURL elements (Option 3 strategy)
|
||||
# Remove all BaseURL elements
|
||||
self._remove_base_urls(root)
|
||||
|
||||
# Rewrite all URLs in the MPD
|
||||
@@ -137,7 +176,7 @@ class MPDRewriter:
|
||||
if not rewritten.startswith("<?xml"):
|
||||
rewritten = '<?xml version="1.0" encoding="UTF-8"?>\n' + rewritten
|
||||
|
||||
logger.debug(f"Successfully rewrote MPD for provider '{self.provider_name}'")
|
||||
logger.debug(f"Successfully rewrote MPD for media proxy")
|
||||
return rewritten
|
||||
|
||||
except ET.ParseError as e:
|
||||
@@ -188,7 +227,7 @@ class MPDRewriter:
|
||||
|
||||
def _remove_base_urls(self, root: ET.Element) -> None:
|
||||
"""
|
||||
Remove all BaseURL elements from MPD (Option 3 strategy)
|
||||
Remove all BaseURL elements from MPD
|
||||
|
||||
Args:
|
||||
root: MPD root element
|
||||
@@ -232,12 +271,12 @@ class MPDRewriter:
|
||||
base_path, template_pattern = self.split_template_url(resolved)
|
||||
element.attrib[attr] = self.build_proxy_url(base_path, template_pattern)
|
||||
logger.debug(
|
||||
f"Rewrote template URL: {original_url} -> proxy with template {template_pattern}"
|
||||
f"Rewrote template URL: {original_url} -> media proxy with template {template_pattern}"
|
||||
)
|
||||
else:
|
||||
# Regular URL without templates
|
||||
element.attrib[attr] = self.build_proxy_url(resolved)
|
||||
logger.debug(f"Rewrote URL: {original_url} -> proxy")
|
||||
logger.debug(f"Rewrote URL: {original_url} -> media proxy")
|
||||
|
||||
# Handle SegmentURL elements (used in SegmentList)
|
||||
if element.tag.endswith("SegmentURL"):
|
||||
@@ -373,4 +412,4 @@ class MPDRewriter:
|
||||
|
||||
total_seconds = int(hours * 3600 + minutes * 60 + seconds)
|
||||
logger.debug(f"Parsed ISO duration '{duration}' to {total_seconds}s")
|
||||
return total_seconds
|
||||
return total_seconds
|
||||
+7
-1
@@ -9,4 +9,10 @@ python-dateutil>=2.8.2
|
||||
urllib3>=2.0.7
|
||||
chardet>=5.2.0
|
||||
pycryptodome>=3.19.0
|
||||
cryptography>=41.0.0
|
||||
|
||||
# Optional but recommended
|
||||
aiohttp>=3.8.5
|
||||
cryptography>=41.0.4
|
||||
|
||||
# For development/debugging
|
||||
python-dotenv>=1.0.0
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Route handlers for Ultimate Backend Service
|
||||
"""
|
||||
|
||||
from .cache import setup_cache_routes
|
||||
from .config import setup_config_routes
|
||||
from .drm import setup_drm_routes
|
||||
from .epg import setup_epg_routes
|
||||
from .m3u import setup_m3u_routes
|
||||
from .providers import setup_provider_routes
|
||||
from .streams import setup_stream_routes
|
||||
|
||||
__all__ = [
|
||||
"setup_provider_routes",
|
||||
"setup_stream_routes",
|
||||
"setup_m3u_routes",
|
||||
"setup_drm_routes",
|
||||
"setup_cache_routes",
|
||||
"setup_config_routes",
|
||||
"setup_epg_routes",
|
||||
]
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cache management route handlers
|
||||
"""
|
||||
|
||||
from bottle import response
|
||||
from streaming_providers.base.utils import logger
|
||||
|
||||
|
||||
def setup_cache_routes(app, manager, service):
|
||||
"""Setup cache management routes"""
|
||||
|
||||
@app.route("/api/cache/pssh", method="DELETE")
|
||||
def clear_pssh_cache():
|
||||
"""
|
||||
Clear all PSSH cache entries.
|
||||
|
||||
This is useful for:
|
||||
- Debugging
|
||||
- Freeing memory
|
||||
- Forcing re-extraction of all channels
|
||||
"""
|
||||
try:
|
||||
cache_size = len(manager.drm_ops.pssh_cache.cache)
|
||||
manager.drm_ops.pssh_cache.clear()
|
||||
|
||||
response.status = 200
|
||||
return {"message": "PSSH cache cleared", "entries_cleared": cache_size}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing cache: {e}")
|
||||
response.status = 500
|
||||
return {"error": "Failed to clear cache", "message": str(e)}
|
||||
|
||||
@app.route("/api/cache/pssh", method="GET")
|
||||
def get_pssh_cache_stats():
|
||||
"""
|
||||
Get PSSH cache statistics.
|
||||
|
||||
Returns information about:
|
||||
- Number of cached entries
|
||||
- TTL configuration
|
||||
- Memory usage estimate
|
||||
"""
|
||||
try:
|
||||
cache = manager.drm_ops.pssh_cache
|
||||
|
||||
with cache.lock:
|
||||
entries = []
|
||||
total_size = 0
|
||||
|
||||
for key, (pssh_list, timestamp) in cache.cache.items():
|
||||
import time
|
||||
|
||||
age = time.time() - timestamp
|
||||
expires_in = cache.ttl - age
|
||||
|
||||
# Estimate size
|
||||
size = sum(
|
||||
len(p.pssh_box) + len(str(p.key_ids)) + len(p.system_id)
|
||||
for p in pssh_list
|
||||
)
|
||||
total_size += size
|
||||
|
||||
entries.append(
|
||||
{
|
||||
"key": key,
|
||||
"pssh_count": len(pssh_list),
|
||||
"age_seconds": int(age),
|
||||
"expires_in_seconds": int(expires_in),
|
||||
"size_bytes": size,
|
||||
}
|
||||
)
|
||||
|
||||
response.status = 200
|
||||
return {
|
||||
"total_entries": len(entries),
|
||||
"ttl_seconds": cache.ttl,
|
||||
"total_size_bytes": total_size,
|
||||
"total_size_mb": round(total_size / 1024 / 1024, 2),
|
||||
"entries": sorted(
|
||||
entries, key=lambda x: x["age_seconds"], reverse=True
|
||||
),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting cache stats: {e}")
|
||||
response.status = 500
|
||||
return {"error": "Failed to get cache stats", "message": str(e)}
|
||||
|
||||
@app.route("/api/cache/mpd/clear")
|
||||
def clear_mpd_cache():
|
||||
"""Clear all cached MPD manifests"""
|
||||
try:
|
||||
service.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)}
|
||||
|
||||
@app.route("/api/cache/mpd/clear-expired")
|
||||
def clear_expired_mpd_cache():
|
||||
"""Clear expired MPD cache entries"""
|
||||
try:
|
||||
cleared = service.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)}
|
||||
|
||||
@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 = service.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)}
|
||||
|
||||
@app.route("/api/cache/mpd/<provider>/<channel_id>/delete")
|
||||
def delete_mpd_cache(provider, channel_id):
|
||||
"""Delete cached MPD for a specific channel"""
|
||||
try:
|
||||
service.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)}
|
||||
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configuration route handlers
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from bottle import request, response
|
||||
from streaming_providers.base.utils import logger
|
||||
|
||||
|
||||
def setup_config_routes(app, manager, service):
|
||||
"""Setup configuration-related routes"""
|
||||
|
||||
@app.route("/api/config/export")
|
||||
def export_config():
|
||||
"""Export all configurations as JSON"""
|
||||
try:
|
||||
settings_manager = service._get_settings_manager()
|
||||
|
||||
# Use SettingsManager's export method
|
||||
export_path = settings_manager.export_all_settings()
|
||||
|
||||
# Read the exported file
|
||||
with open(export_path, "r", encoding="utf-8") as f:
|
||||
config_data = json.load(f)
|
||||
|
||||
response.content_type = "application/json"
|
||||
response.headers["Content-Disposition"] = (
|
||||
f'attachment; filename="{os.path.basename(export_path)}"'
|
||||
)
|
||||
return json.dumps(config_data, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting config: {e}")
|
||||
response.status = 500
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.route("/api/config/import", method="POST")
|
||||
def import_config():
|
||||
"""Import configurations from JSON"""
|
||||
try:
|
||||
import_data = request.json
|
||||
except ValueError:
|
||||
response.status = 400
|
||||
return {"error": "Invalid JSON format"}
|
||||
|
||||
if not import_data:
|
||||
response.status = 400
|
||||
return {"error": "No data provided"}
|
||||
|
||||
# Validate it's a dict
|
||||
if not isinstance(import_data, dict):
|
||||
response.status = 400
|
||||
return {"error": "Import data must be a JSON object"}
|
||||
|
||||
# Create temp file
|
||||
try:
|
||||
import json
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_file = os.path.join(temp_dir, f"import_{uuid.uuid4()}.json")
|
||||
|
||||
with open(temp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(import_data, f)
|
||||
except (IOError, OSError, PermissionError) as file_err:
|
||||
logger.error(f"Failed to create temp file: {file_err}")
|
||||
response.status = 500
|
||||
return {"error": "Failed to process import file"}
|
||||
|
||||
imported_count = 0
|
||||
try:
|
||||
# Use SettingsManager to import
|
||||
settings_manager = service._get_settings_manager()
|
||||
|
||||
# Import credentials
|
||||
credentials = import_data.get("providers", {})
|
||||
|
||||
for provider_name, provider_data in credentials.items():
|
||||
# Validate provider data
|
||||
if not isinstance(provider_data, dict):
|
||||
logger.warning(
|
||||
f"Skipping invalid provider data for {provider_name}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Extract credential data if available
|
||||
if "credentials" in provider_data:
|
||||
cred_data = provider_data["credentials"]
|
||||
if isinstance(cred_data, dict):
|
||||
success, message = (
|
||||
settings_manager.save_provider_credentials_from_api(
|
||||
provider_name, cred_data
|
||||
)
|
||||
)
|
||||
if success:
|
||||
imported_count += 1
|
||||
logger.info(f"Imported credentials for {provider_name}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to import credentials for {provider_name}: {message}"
|
||||
)
|
||||
|
||||
# Import proxy data if available
|
||||
if "proxy" in provider_data:
|
||||
proxy_data = provider_data["proxy"]
|
||||
if isinstance(proxy_data, dict):
|
||||
success, message = (
|
||||
settings_manager.save_provider_proxy_from_api(
|
||||
provider_name, proxy_data
|
||||
)
|
||||
)
|
||||
if success:
|
||||
imported_count += 1
|
||||
logger.info(f"Imported proxy for {provider_name}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to import proxy for {provider_name}: {message}"
|
||||
)
|
||||
|
||||
except Exception as process_err:
|
||||
logger.error(
|
||||
f"Error during import processing: {process_err}", exc_info=True
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Import failed: {str(process_err)}"}
|
||||
|
||||
finally:
|
||||
# Always try to clean up temp file
|
||||
try:
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
except (FileNotFoundError, PermissionError, OSError) as cleanup_err:
|
||||
logger.debug(f"Could not remove temp file {temp_file}: {cleanup_err}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"imported": imported_count,
|
||||
"message": f"Imported {imported_count} configurations",
|
||||
}
|
||||
|
||||
@app.route("/api/config/epg", method="GET")
|
||||
def get_epg_config():
|
||||
"""Get current EPG configuration"""
|
||||
try:
|
||||
from streaming_providers.base.utils.environment import (
|
||||
get_environment_manager,
|
||||
)
|
||||
|
||||
env_mgr = get_environment_manager()
|
||||
|
||||
config = {
|
||||
"epg_url": env_mgr.get_config("epg_url", ""),
|
||||
"epg_cache_ttl": env_mgr.get_config("epg_cache_ttl", 86400),
|
||||
"source": (
|
||||
"config.json" if env_mgr.get_config("epg_url") else "default"
|
||||
),
|
||||
}
|
||||
|
||||
# Also check environment variable for reference
|
||||
import os
|
||||
|
||||
env_epg_url = os.environ.get("ULTIMATE_EPG_URL")
|
||||
if env_epg_url:
|
||||
config["environment_variable"] = env_epg_url
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"config": config,
|
||||
"epg_manager_status": (
|
||||
"initialized"
|
||||
if hasattr(service, "epg_manager")
|
||||
else "not_initialized"
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"API Error in /api/config/epg: {str(e)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route("/api/config/epg", method="POST")
|
||||
def set_epg_config():
|
||||
"""Set EPG configuration"""
|
||||
try:
|
||||
# Parse JSON body
|
||||
try:
|
||||
epg_data = request.json
|
||||
except Exception as json_err:
|
||||
logger.error(f"Invalid JSON in request body: {json_err}")
|
||||
response.status = 400
|
||||
return {"error": "Invalid JSON in request body"}
|
||||
|
||||
if not epg_data:
|
||||
response.status = 400
|
||||
return {"error": "Request body must contain EPG configuration"}
|
||||
|
||||
# Validate it's a dictionary
|
||||
if not isinstance(epg_data, dict):
|
||||
response.status = 400
|
||||
return {"error": "EPG data must be a JSON object"}
|
||||
|
||||
# Validate URL format
|
||||
epg_url = epg_data.get("epg_url", "").strip()
|
||||
if epg_url:
|
||||
# Basic URL validation
|
||||
if not (
|
||||
epg_url.startswith("http://") or epg_url.startswith("https://")
|
||||
):
|
||||
response.status = 400
|
||||
return {"error": "EPG URL must start with http:// or https://"}
|
||||
|
||||
# Validate it's an XML/GZ file
|
||||
if not (
|
||||
epg_url.endswith(".xml")
|
||||
or epg_url.endswith(".xml.gz")
|
||||
or epg_url.endswith(".gz")
|
||||
):
|
||||
logger.warning(f"EPG URL doesn't end with .xml or .gz: {epg_url}")
|
||||
|
||||
# Use environment manager
|
||||
from streaming_providers.base.utils.environment import (
|
||||
get_environment_manager,
|
||||
)
|
||||
|
||||
env_mgr = get_environment_manager()
|
||||
|
||||
# Get current config
|
||||
import json
|
||||
import os
|
||||
|
||||
profile_path = env_mgr.get_config("profile_path", "")
|
||||
config_file = os.path.join(profile_path, "config.json")
|
||||
config_data = {}
|
||||
|
||||
if os.path.exists(config_file):
|
||||
try:
|
||||
with open(config_file, "r", encoding="utf-8") as f:
|
||||
config_data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading config.json: {e}")
|
||||
config_data = {}
|
||||
|
||||
# Update with new values
|
||||
config_data["epg_url"] = epg_url
|
||||
|
||||
# Optional: EPG cache TTL
|
||||
if "epg_cache_ttl" in epg_data:
|
||||
try:
|
||||
ttl = int(epg_data["epg_cache_ttl"])
|
||||
if ttl > 0:
|
||||
config_data["epg_cache_ttl"] = ttl
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Save back to config.json
|
||||
try:
|
||||
with open(config_file, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Update environment manager cache
|
||||
env_mgr.set_config("epg_url", epg_url)
|
||||
if "epg_cache_ttl" in config_data:
|
||||
env_mgr.set_config("epg_cache_ttl", config_data["epg_cache_ttl"])
|
||||
|
||||
logger.info(f"Updated EPG configuration: URL={epg_url}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "EPG configuration updated successfully",
|
||||
"config": {
|
||||
"epg_url": epg_url,
|
||||
"epg_cache_ttl": config_data.get("epg_cache_ttl", 86400),
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing config.json: {e}")
|
||||
response.status = 500
|
||||
return {"error": f"Failed to save configuration: {str(e)}"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"API Error in POST /api/config/epg: {str(e)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route("/api/config/epg/clear-cache", method="POST")
|
||||
def clear_epg_cache():
|
||||
"""Clear EPG cache"""
|
||||
try:
|
||||
if hasattr(service, "epg_manager") and service.epg_manager:
|
||||
success = service.epg_manager.clear_cache()
|
||||
if success:
|
||||
return {"success": True, "message": "EPG cache cleared"}
|
||||
else:
|
||||
response.status = 500
|
||||
return {"error": "Failed to clear EPG cache"}
|
||||
else:
|
||||
response.status = 404
|
||||
return {"error": "EPG manager not initialized"}
|
||||
except Exception as e:
|
||||
logger.error(f"API Error clearing EPG cache: {str(e)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route("/api/config/epg/cache-info", method="GET")
|
||||
def get_epg_cache_info():
|
||||
"""Get EPG cache information"""
|
||||
try:
|
||||
if hasattr(service, "epg_manager") and service.epg_manager:
|
||||
cache_info = service.epg_manager.get_cache_info()
|
||||
mapping_stats = service.epg_manager.get_mapping_stats()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"cache_info": cache_info,
|
||||
"mapping_stats": mapping_stats,
|
||||
}
|
||||
else:
|
||||
response.status = 404
|
||||
return {"error": "EPG manager not initialized"}
|
||||
except Exception as e:
|
||||
logger.error(f"API Error getting EPG cache info: {str(e)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DRM and PSSH route handlers
|
||||
"""
|
||||
|
||||
import traceback
|
||||
|
||||
from bottle import request, response
|
||||
from streaming_providers.base.utils import logger
|
||||
|
||||
|
||||
def setup_drm_routes(app, manager, service):
|
||||
"""Setup DRM and PSSH-related routes"""
|
||||
|
||||
@app.route("/api/providers/<provider>/channels/<channel_id>/pssh")
|
||||
def get_channel_pssh(provider, channel_id):
|
||||
"""
|
||||
Extract PSSH data for a channel.
|
||||
|
||||
Query parameters:
|
||||
- country: Optional country code for geo-specific manifests
|
||||
- force_refresh: If 'true', bypass cache and re-extract PSSH
|
||||
|
||||
Returns:
|
||||
{
|
||||
"provider": "provider_name",
|
||||
"channel_id": "channel_id",
|
||||
"manifest_url": "https://...",
|
||||
"pssh_data": [
|
||||
{
|
||||
"system_id": "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
|
||||
"drm_system": "com.widevine.alpha",
|
||||
"pssh_box": "AAAANHBzc2g...",
|
||||
"key_ids": ["64656d6f..."],
|
||||
"source": "mp4_segment"
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
"cached": true
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Parse query parameters
|
||||
country = request.params.get("country")
|
||||
force_refresh = request.params.get("force_refresh", "").lower() == "true"
|
||||
|
||||
# Check cache status BEFORE clearing (to know if it was cached)
|
||||
cache_key = f"{provider}:{channel_id}"
|
||||
was_cached_before = manager.drm_ops.pssh_cache.get(cache_key) is not None
|
||||
|
||||
# Clear cache if force refresh requested
|
||||
if force_refresh:
|
||||
with manager.drm_ops.pssh_cache.lock:
|
||||
if cache_key in manager.drm_ops.pssh_cache.cache:
|
||||
del manager.drm_ops.pssh_cache.cache[cache_key]
|
||||
logger.info(f"Cache cleared for force_refresh request: {cache_key}")
|
||||
was_cached_before = False # Since we just cleared it
|
||||
|
||||
# Get DRM configs - this will check cache and extract if needed
|
||||
try:
|
||||
# This method handles caching internally
|
||||
drm_configs = manager.drm_ops.get_channel_drm_configs(
|
||||
provider_name=provider, channel_id=channel_id, country=country
|
||||
)
|
||||
except ValueError as e:
|
||||
response.status = 404
|
||||
return {
|
||||
"error": "Provider not found",
|
||||
"message": str(e),
|
||||
"provider": provider,
|
||||
}
|
||||
except Exception as e:
|
||||
response.status = 500
|
||||
return {
|
||||
"error": "Failed to get DRM configs",
|
||||
"message": str(e),
|
||||
"provider": provider,
|
||||
"channel_id": channel_id,
|
||||
}
|
||||
|
||||
# Get manifest URL for reference
|
||||
try:
|
||||
manifest_url = manager.get_channel_manifest(
|
||||
provider_name=provider, channel_id=channel_id, country=country
|
||||
)
|
||||
except Exception as e:
|
||||
manifest_url = None
|
||||
logger.debug(f"Could not get manifest URL: {e}")
|
||||
|
||||
# Now get the PSSH data from cache (after get_channel_drm_configs has populated it)
|
||||
pssh_data_list = manager.drm_ops.pssh_cache.get(cache_key)
|
||||
was_cached_after = pssh_data_list is not None
|
||||
|
||||
if not pssh_data_list:
|
||||
# If no PSSH data in cache, try to extract from manifest
|
||||
if manifest_url:
|
||||
try:
|
||||
pssh_data_list = manager.drm_ops._extract_pssh_from_manifest(
|
||||
manifest_url
|
||||
)
|
||||
# Cache the result
|
||||
if pssh_data_list:
|
||||
manager.drm_ops.pssh_cache.set(cache_key, pssh_data_list)
|
||||
except Exception as extract_err:
|
||||
logger.warning(
|
||||
f"Failed to extract PSSH from manifest: {extract_err}"
|
||||
)
|
||||
pssh_data_list = []
|
||||
else:
|
||||
pssh_data_list = []
|
||||
|
||||
# Convert PSSH data to dictionary format
|
||||
pssh_list = []
|
||||
for pssh_data in pssh_data_list:
|
||||
pssh_dict = {
|
||||
"system_id": pssh_data.system_id,
|
||||
"drm_system": (
|
||||
pssh_data.drm_system.value if pssh_data.drm_system else None
|
||||
),
|
||||
"pssh_box": pssh_data.pssh_box if pssh_data.pssh_box else None,
|
||||
"key_ids": pssh_data.key_ids if pssh_data.key_ids else [],
|
||||
"source": pssh_data.source,
|
||||
}
|
||||
|
||||
# Add human-readable system name
|
||||
if pssh_data.drm_system:
|
||||
pssh_dict["drm_system_name"] = {
|
||||
"com.widevine.alpha": "Widevine",
|
||||
"com.microsoft.playready": "PlayReady",
|
||||
"com.apple.fps": "FairPlay",
|
||||
"org.w3.clearkey": "ClearKey",
|
||||
"com.huawei.wiseplay": "Wiseplay",
|
||||
}.get(pssh_data.drm_system.value, pssh_data.drm_system.value)
|
||||
|
||||
# Remove None values for cleaner response
|
||||
pssh_dict = {k: v for k, v in pssh_dict.items() if v is not None}
|
||||
pssh_list.append(pssh_dict)
|
||||
|
||||
response.status = 200
|
||||
return {
|
||||
"provider": provider,
|
||||
"channel_id": channel_id,
|
||||
"manifest_url": manifest_url,
|
||||
"pssh_data": pssh_list,
|
||||
"count": len(pssh_list),
|
||||
"cached": was_cached_after, # Whether data came from cache AFTER the operation
|
||||
"was_cached_before": was_cached_before, # Whether data was in cache BEFORE the operation
|
||||
"cache_ttl_seconds": manager.drm_ops.pssh_cache.ttl,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# Catch-all for unexpected errors
|
||||
logger.error(f"Unexpected error in get_channel_pssh: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
response.status = 500
|
||||
return {
|
||||
"error": "Internal server error",
|
||||
"message": str(e),
|
||||
"provider": provider,
|
||||
"channel_id": channel_id,
|
||||
"traceback": (
|
||||
traceback.format_exc() if app.config.get("debug") else None
|
||||
),
|
||||
}
|
||||
|
||||
@app.route(
|
||||
"/api/providers/<provider>/channels/<channel_id>/pssh/refresh",
|
||||
method="POST",
|
||||
)
|
||||
def refresh_channel_pssh(provider, channel_id):
|
||||
"""
|
||||
Force refresh PSSH data for a channel (clears cache and re-extracts).
|
||||
|
||||
This is useful when:
|
||||
- Keys have been rotated
|
||||
- Manifest structure has changed
|
||||
- Previous extraction failed
|
||||
"""
|
||||
try:
|
||||
# Parse query parameters
|
||||
country = request.params.get("country")
|
||||
|
||||
# Generate cache key
|
||||
cache_key = f"{provider}:{channel_id}"
|
||||
|
||||
# Check if entry exists in cache before clearing
|
||||
was_cached = manager.drm_ops.pssh_cache.get(cache_key) is not None
|
||||
|
||||
# Clear the specific cache entry
|
||||
with manager.drm_ops.pssh_cache.lock:
|
||||
if cache_key in manager.drm_ops.pssh_cache.cache:
|
||||
del manager.drm_ops.pssh_cache.cache[cache_key]
|
||||
|
||||
if was_cached:
|
||||
logger.info(f"Cleared cache for {cache_key}")
|
||||
else:
|
||||
logger.info(f"No cache entry found for {cache_key}")
|
||||
|
||||
# Now extract fresh data by calling get_channel_drm_configs
|
||||
# This will force a fresh extraction since cache was cleared
|
||||
try:
|
||||
drm_configs = manager.drm_ops.get_channel_drm_configs(
|
||||
provider_name=provider, channel_id=channel_id, country=country
|
||||
)
|
||||
except ValueError as e:
|
||||
response.status = 404
|
||||
return {
|
||||
"error": "Provider not found",
|
||||
"message": str(e),
|
||||
"provider": provider,
|
||||
}
|
||||
except Exception as e:
|
||||
response.status = 500
|
||||
return {
|
||||
"error": "Failed to refresh DRM configs",
|
||||
"message": str(e),
|
||||
"provider": provider,
|
||||
"channel_id": channel_id,
|
||||
}
|
||||
|
||||
# Get the newly cached PSSH data
|
||||
pssh_data_list = manager.drm_ops.pssh_cache.get(cache_key)
|
||||
now_cached = pssh_data_list is not None
|
||||
|
||||
# Get manifest URL for reference
|
||||
try:
|
||||
manifest_url = manager.get_channel_manifest(
|
||||
provider_name=provider, channel_id=channel_id, country=country
|
||||
)
|
||||
except Exception as e:
|
||||
manifest_url = None
|
||||
|
||||
response.status = 200
|
||||
return {
|
||||
"message": "PSSH data refreshed successfully",
|
||||
"provider": provider,
|
||||
"channel_id": channel_id,
|
||||
"manifest_url": manifest_url,
|
||||
"pssh_count": len(pssh_data_list) if pssh_data_list else 0,
|
||||
"was_cached": was_cached,
|
||||
"now_cached": now_cached,
|
||||
"extraction_successful": (
|
||||
len(pssh_data_list) > 0 if pssh_data_list else False
|
||||
),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing PSSH: {e}")
|
||||
response.status = 500
|
||||
return {"error": "Failed to refresh PSSH data", "message": str(e)}
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
EPG-related route handlers
|
||||
"""
|
||||
|
||||
from bottle import request, response
|
||||
from streaming_providers.base.utils import logger
|
||||
|
||||
|
||||
def setup_epg_routes(app, manager, service):
|
||||
"""Setup EPG-related routes"""
|
||||
|
||||
@app.route("/api/epg/status", method="GET")
|
||||
def get_epg_status():
|
||||
"""Get EPG configuration and cache status"""
|
||||
try:
|
||||
result = {
|
||||
"configured": bool(service.epg_url)
|
||||
and service.epg_url != "https://example.com/epg.xml.gz",
|
||||
"epg_url": service.epg_url if service.epg_url else "Not configured",
|
||||
"cache_valid": False,
|
||||
"cache_path": None,
|
||||
"channel_count": 0,
|
||||
"environment_used": False,
|
||||
}
|
||||
|
||||
# Check if we used the environment variable
|
||||
import os
|
||||
|
||||
env_url = os.environ.get("ULTIMATE_EPG_URL")
|
||||
if env_url and env_url == service.epg_url:
|
||||
result["environment_used"] = True
|
||||
|
||||
if (
|
||||
result["configured"]
|
||||
and hasattr(service, "epg_manager")
|
||||
and service.epg_manager
|
||||
):
|
||||
try:
|
||||
cache = service.epg_manager.cache
|
||||
xml_path = cache.get_cached_file_path()
|
||||
|
||||
if xml_path:
|
||||
result["cache_valid"] = True
|
||||
result["cache_path"] = xml_path
|
||||
|
||||
# Try to count channels
|
||||
import gzip
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
def open_xml_file(file_path):
|
||||
if file_path.endswith(".gz"):
|
||||
return gzip.open(file_path, "rt", encoding="utf-8")
|
||||
else:
|
||||
return open(file_path, "r", encoding="utf-8")
|
||||
|
||||
channel_ids = set()
|
||||
try:
|
||||
with open_xml_file(xml_path) as xml_file:
|
||||
context = ET.iterparse(xml_file, events=("start",))
|
||||
for event, elem in context:
|
||||
if elem.tag == "channel":
|
||||
channel_id = elem.get("id")
|
||||
if channel_id:
|
||||
channel_ids.add(channel_id)
|
||||
elem.clear()
|
||||
result["channel_count"] = len(channel_ids)
|
||||
except Exception as parse_err:
|
||||
result["parse_error"] = str(parse_err)
|
||||
except Exception as cache_err:
|
||||
result["cache_error"] = str(cache_err)
|
||||
else:
|
||||
result["hint"] = "Please configure EPG URL in Advanced settings"
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting EPG status: {e}")
|
||||
response.status = 500
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.route("/api/epg/xmltv-channels", method="GET")
|
||||
def get_epg_xmltv_channels():
|
||||
"""Get all unique channel IDs from EPG XML file with display names"""
|
||||
try:
|
||||
# Check if EPG manager is available
|
||||
if not hasattr(service, "epg_manager") or not service.epg_manager:
|
||||
response.status = 404
|
||||
return {"error": "EPG module not available"}
|
||||
|
||||
# Check if we have a valid EPG URL configured
|
||||
if (
|
||||
not service.epg_url
|
||||
or service.epg_url == "https://example.com/epg.xml.gz"
|
||||
):
|
||||
response.status = 400
|
||||
return {
|
||||
"error": "EPG URL not configured",
|
||||
"hint": "Please configure a valid EPG URL in Advanced settings",
|
||||
"current_url": service.epg_url,
|
||||
}
|
||||
|
||||
# Get the cache manager from EPG manager
|
||||
cache = service.epg_manager.cache
|
||||
|
||||
logger.info(f"EPG Channels: Using URL: {service.epg_url}")
|
||||
|
||||
# This will download if not cached, or return cached path
|
||||
xml_path = cache.get_or_download(service.epg_url)
|
||||
|
||||
if not xml_path:
|
||||
response.status = 404
|
||||
return {
|
||||
"error": f"EPG file not available from {service.epg_url}",
|
||||
"details": "Failed to download or cache EPG file.",
|
||||
"hint": "Check if the URL is accessible and contains valid XMLTV data.",
|
||||
}
|
||||
|
||||
# Parse XML to get channel IDs and display names
|
||||
import gzip
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
if not os.path.exists(xml_path):
|
||||
response.status = 404
|
||||
return {"error": f"EPG file does not exist at path: {xml_path}"}
|
||||
|
||||
channel_ids = []
|
||||
channel_map = {} # Map of id -> display name
|
||||
|
||||
def open_xml_file(file_path):
|
||||
if file_path.endswith(".gz"):
|
||||
return gzip.open(file_path, "rt", encoding="utf-8")
|
||||
else:
|
||||
return open(file_path, "r", encoding="utf-8")
|
||||
|
||||
logger.info(f"Parsing EPG file: {xml_path}")
|
||||
file_size = os.path.getsize(xml_path)
|
||||
logger.info(f"EPG file size: {file_size} bytes")
|
||||
|
||||
with open_xml_file(xml_path) as xml_file:
|
||||
# Use iterparse for memory efficiency
|
||||
context = ET.iterparse(xml_file, events=("start", "end"))
|
||||
|
||||
current_channel_id = None
|
||||
current_display_names = []
|
||||
|
||||
for event, elem in context:
|
||||
if event == "start" and elem.tag == "channel":
|
||||
current_channel_id = elem.get("id")
|
||||
current_display_names = []
|
||||
|
||||
elif event == "end" and elem.tag == "display-name":
|
||||
if current_channel_id and elem.text:
|
||||
current_display_names.append(elem.text.strip())
|
||||
|
||||
elif event == "end" and elem.tag == "channel":
|
||||
if current_channel_id:
|
||||
channel_ids.append(current_channel_id)
|
||||
# Use the first display name as the primary name
|
||||
if current_display_names:
|
||||
channel_map[current_channel_id] = current_display_names[
|
||||
0
|
||||
]
|
||||
else:
|
||||
channel_map[current_channel_id] = current_channel_id
|
||||
current_channel_id = None
|
||||
|
||||
# Clear element to save memory
|
||||
if event == "end":
|
||||
elem.clear()
|
||||
|
||||
logger.info(f"Found {len(channel_ids)} channels in EPG")
|
||||
|
||||
# Sort channels for consistent output
|
||||
sorted_channels = sorted(channel_ids)
|
||||
|
||||
return {
|
||||
"channels": sorted_channels,
|
||||
"channel_map": channel_map, # NEW: Map of id -> display name
|
||||
"count": len(sorted_channels),
|
||||
"source_url": service.epg_url,
|
||||
"cache_path": xml_path,
|
||||
"cache_size_bytes": file_size,
|
||||
}
|
||||
|
||||
except ET.ParseError as parse_err:
|
||||
logger.error(f"XML parse error in EPG file: {parse_err}")
|
||||
response.status = 500
|
||||
return {
|
||||
"error": f"Failed to parse EPG XML file: {str(parse_err)}",
|
||||
"hint": "The EPG file may be malformed or not valid XMLTV format.",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting EPG channels: {e}", exc_info=True)
|
||||
response.status = 500
|
||||
return {"error": f"Failed to process EPG file: {str(e)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/epg-mapping", method="GET")
|
||||
def get_epg_mapping(provider):
|
||||
"""Get current EPG mapping for a provider"""
|
||||
try:
|
||||
from streaming_providers.base.utils.vfs import VFS
|
||||
except ImportError:
|
||||
# If VFS is not available, return empty mapping
|
||||
return {"provider": provider, "mapping": {}, "exists": False}
|
||||
|
||||
try:
|
||||
mapping_file = f"{provider}_epg_mapping.json"
|
||||
vfs = VFS(addon_subdir="")
|
||||
|
||||
if vfs.exists(mapping_file):
|
||||
mapping_data = vfs.read_json(mapping_file)
|
||||
if mapping_data:
|
||||
# The file structure is:
|
||||
# {
|
||||
# "_provider_name": "...",
|
||||
# "channel_id": {"epg_id": "...", "name": "..."}
|
||||
# }
|
||||
|
||||
# Extract mapping (skip internal fields starting with _)
|
||||
internal_fields = [
|
||||
"_provider_name",
|
||||
"_created_at",
|
||||
"_updated_at",
|
||||
"_version",
|
||||
]
|
||||
actual_mapping = {
|
||||
k: v
|
||||
for k, v in mapping_data.items()
|
||||
if k not in internal_fields
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Loaded EPG mapping for {provider}: {len(actual_mapping)} channels"
|
||||
)
|
||||
logger.debug(f"Sample mappings: {list(actual_mapping.items())[:3]}")
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"mapping": actual_mapping,
|
||||
"exists": True,
|
||||
}
|
||||
|
||||
# Return empty mapping if file doesn't exist
|
||||
logger.info(f"No EPG mapping file found for {provider}")
|
||||
return {"provider": provider, "mapping": {}, "exists": False}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error getting EPG mapping for {provider}: {e}", exc_info=True
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Failed to load mapping: {str(e)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/epg-mapping", method="POST")
|
||||
def save_epg_mapping(provider):
|
||||
"""Save EPG mapping for a provider"""
|
||||
try:
|
||||
from streaming_providers.base.utils.vfs import VFS
|
||||
except ImportError:
|
||||
response.status = 500
|
||||
return {"error": "VFS module not available"}
|
||||
|
||||
try:
|
||||
# Get JSON data from request body using Bottle's request object
|
||||
try:
|
||||
mapping_data = request.json.get("mapping", {}) if request.json else {}
|
||||
except Exception as json_err:
|
||||
logger.error(f"Invalid JSON in request body: {json_err}")
|
||||
response.status = 400
|
||||
return {"error": "Invalid JSON in request body"}
|
||||
|
||||
# Get provider label if available
|
||||
provider_label = provider
|
||||
try:
|
||||
provider_instance = manager.get_provider(provider)
|
||||
if provider_instance:
|
||||
provider_label = getattr(
|
||||
provider_instance, "provider_label", provider
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Build the file structure:
|
||||
# {
|
||||
# "_provider_name": "Provider Label",
|
||||
# "channel_id": {"epg_id": "...", "name": "..."}
|
||||
# }
|
||||
full_mapping = {"_provider_name": provider_label}
|
||||
|
||||
# Add each mapping entry
|
||||
for channel_id, mapping_value in mapping_data.items():
|
||||
if isinstance(mapping_value, dict):
|
||||
# Already has structure {"epg_id": "...", "name": "..."}
|
||||
full_mapping[channel_id] = mapping_value
|
||||
elif isinstance(mapping_value, str):
|
||||
# Simple string, convert to object
|
||||
full_mapping[channel_id] = {
|
||||
"epg_id": mapping_value,
|
||||
"name": "", # Name not provided
|
||||
}
|
||||
|
||||
# Save to file
|
||||
vfs = VFS(addon_subdir="")
|
||||
mapping_file = f"{provider}_epg_mapping.json"
|
||||
|
||||
success = vfs.write_json(mapping_file, full_mapping)
|
||||
|
||||
if success:
|
||||
logger.info(
|
||||
f"Saved EPG mapping for {provider}: {len(mapping_data)} channels"
|
||||
)
|
||||
|
||||
# Clear mapping cache if it exists
|
||||
try:
|
||||
from streaming_providers.base.epg.epg_mapping import EPGMapping
|
||||
|
||||
mapping_manager = EPGMapping()
|
||||
mapping_manager.reload_mapping(provider)
|
||||
logger.info(f"Reloaded EPG mapping cache for {provider}")
|
||||
except ImportError:
|
||||
logger.debug("EPGMapping not available for cache reload")
|
||||
pass
|
||||
except Exception as reload_err:
|
||||
logger.warning(f"Could not reload mapping cache: {reload_err}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Mapping saved for {provider}",
|
||||
"channels_mapped": len(mapping_data),
|
||||
}
|
||||
else:
|
||||
response.status = 500
|
||||
return {"error": "Failed to save mapping file"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving EPG mapping for {provider}: {e}", exc_info=True)
|
||||
response.status = 500
|
||||
return {"error": f"Failed to save mapping: {str(e)}"}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
M3U playlist route handlers
|
||||
"""
|
||||
|
||||
from bottle import request, response
|
||||
from streaming_providers.base.utils import logger
|
||||
|
||||
|
||||
def setup_m3u_routes(app, manager, service):
|
||||
"""Setup M3U playlist-related routes"""
|
||||
|
||||
@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 = service.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 service._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)}"}
|
||||
|
||||
@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 service._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)}"}
|
||||
|
||||
@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 = service.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 service._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)}"}
|
||||
|
||||
@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 service._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)}"}
|
||||
|
||||
@app.route("/api/m3u/decrypted")
|
||||
def get_m3u_decrypted():
|
||||
"""
|
||||
Generates decrypted M3U playlist for all configured providers.
|
||||
Only includes channels with ClearKey DRM or unencrypted channels.
|
||||
Returns cached version if available, otherwise generates new one.
|
||||
|
||||
Example: http://localhost:7777/api/m3u/decrypted
|
||||
"""
|
||||
try:
|
||||
cache_file = "playlist_decrypted.m3u"
|
||||
|
||||
# Try to read cached file
|
||||
cached_content = service.vfs.read_text(cache_file)
|
||||
|
||||
if cached_content:
|
||||
logger.info("Serving cached decrypted M3U playlist for all providers")
|
||||
response.content_type = "audio/x-mpegurl; charset=utf-8"
|
||||
response.headers["Content-Disposition"] = (
|
||||
'attachment; filename="playlist_decrypted.m3u8"'
|
||||
)
|
||||
return cached_content
|
||||
|
||||
# Cache doesn't exist, generate new M3U
|
||||
logger.info(
|
||||
"No valid cache found, generating decrypted M3U playlist for all providers"
|
||||
)
|
||||
return service._generate_m3u_decrypted_all(save_to_cache=True)
|
||||
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/m3u/decrypted: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/m3u/decrypted/generate")
|
||||
def generate_m3u_decrypted():
|
||||
"""
|
||||
Forces regeneration of decrypted M3U playlist for all providers and saves to cache.
|
||||
|
||||
Example: http://localhost:7777/api/m3u/decrypted/generate
|
||||
"""
|
||||
try:
|
||||
logger.info("Force generating decrypted M3U playlist for all providers")
|
||||
return service._generate_m3u_decrypted_all(save_to_cache=True)
|
||||
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/m3u/decrypted/generate: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/m3u/decrypted")
|
||||
def get_m3u_decrypted_provider(provider):
|
||||
"""
|
||||
Generates decrypted M3U playlist for a specific provider.
|
||||
Only includes channels with ClearKey DRM or unencrypted channels.
|
||||
Returns cached version if available, otherwise generates new one.
|
||||
|
||||
Example: http://localhost:7777/api/providers/rtlplus/m3u/decrypted
|
||||
"""
|
||||
try:
|
||||
cache_file = f"{provider}_decrypted.m3u"
|
||||
|
||||
# Try to read cached file
|
||||
cached_content = service.vfs.read_text(cache_file)
|
||||
|
||||
if cached_content:
|
||||
logger.info(
|
||||
f"Serving cached decrypted M3U playlist for provider '{provider}'"
|
||||
)
|
||||
response.content_type = "audio/x-mpegurl; charset=utf-8"
|
||||
response.headers["Content-Disposition"] = (
|
||||
f'attachment; filename="{provider}_decrypted_playlist.m3u8"'
|
||||
)
|
||||
return cached_content
|
||||
|
||||
# Cache doesn't exist, generate new M3U
|
||||
logger.info(
|
||||
f"No valid cache found, generating decrypted M3U playlist for provider '{provider}'"
|
||||
)
|
||||
return service._generate_m3u_decrypted_provider(
|
||||
provider, save_to_cache=True
|
||||
)
|
||||
|
||||
except ValueError as val_err:
|
||||
logger.error(
|
||||
f"API Error in /api/providers/{provider}/m3u/decrypted: {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/decrypted: {str(api_err)}"
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/m3u/decrypted/generate")
|
||||
def generate_m3u_decrypted_provider(provider):
|
||||
"""
|
||||
Forces regeneration of decrypted M3U playlist for a specific provider and saves to cache.
|
||||
|
||||
Example: http://localhost:7777/api/providers/rtlplus/m3u/decrypted/generate
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"Force generating decrypted M3U playlist for provider '{provider}'"
|
||||
)
|
||||
return service._generate_m3u_decrypted_provider(
|
||||
provider, save_to_cache=True
|
||||
)
|
||||
|
||||
except ValueError as val_err:
|
||||
logger.error(
|
||||
f"API Error in /api/providers/{provider}/m3u/decrypted/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/decrypted/generate: {str(api_err)}"
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/m3u/subscribed")
|
||||
def get_m3u_subscribed():
|
||||
"""
|
||||
Generate M3U playlist with only subscribed channels.
|
||||
|
||||
Note: This uses the same caching mechanism as regular M3U,
|
||||
but with '_subscribed' suffix in cache filename.
|
||||
"""
|
||||
try:
|
||||
cache_file = "playlist_subscribed.m3u"
|
||||
|
||||
# Try cached version
|
||||
cached_content = service.vfs.read_text(cache_file)
|
||||
if cached_content:
|
||||
logger.info("Serving cached subscribed M3U playlist")
|
||||
response.content_type = "audio/x-mpegurl; charset=utf-8"
|
||||
response.headers["Content-Disposition"] = (
|
||||
'attachment; filename="playlist_subscribed.m3u8"'
|
||||
)
|
||||
return cached_content
|
||||
|
||||
# Generate new M3U with subscribed channels
|
||||
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
|
||||
m3u_content = "#EXTM3U\n"
|
||||
|
||||
provider_list = manager.list_providers()
|
||||
for provider_name in provider_list:
|
||||
try:
|
||||
channels = manager.get_subscribed_channels(provider_name)
|
||||
|
||||
for channel in channels:
|
||||
# Generate M3U entry for each subscribed channel
|
||||
channel_id = channel.channel_id
|
||||
channel_name = channel.name
|
||||
channel_logo = channel.logo_url or ""
|
||||
|
||||
# Get provider label
|
||||
try:
|
||||
provider_instance = manager.get_provider(provider_name)
|
||||
provider_label = getattr(
|
||||
provider_instance, "provider_label", provider_name
|
||||
)
|
||||
except:
|
||||
provider_label = provider_name
|
||||
|
||||
# Build stream URL
|
||||
stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream"
|
||||
|
||||
# Add M3U entry
|
||||
m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}" tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
|
||||
|
||||
# Add DRM directives if available
|
||||
try:
|
||||
drm_configs = manager.get_channel_drm_configs(
|
||||
provider_name, channel_id
|
||||
)
|
||||
if drm_configs:
|
||||
drm_directives = service._generate_drm_directives(
|
||||
drm_configs
|
||||
)
|
||||
m3u_content += drm_directives
|
||||
except Exception as drm_err:
|
||||
logger.debug(
|
||||
f"Could not get DRM for {provider_name}/{channel_id}: {drm_err}"
|
||||
)
|
||||
|
||||
m3u_content += f"{stream_url}\n"
|
||||
|
||||
except Exception as provider_err:
|
||||
logger.warning(
|
||||
f"Failed to process subscribed channels for '{provider_name}': {str(provider_err)}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Cache the result
|
||||
if service.vfs.write_text(cache_file, m3u_content):
|
||||
logger.info(f"Subscribed M3U playlist cached to {cache_file}")
|
||||
|
||||
response.content_type = "audio/x-mpegurl; charset=utf-8"
|
||||
response.headers["Content-Disposition"] = (
|
||||
'attachment; filename="playlist_subscribed.m3u8"'
|
||||
)
|
||||
return m3u_content
|
||||
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/m3u/subscribed: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/m3u/subscribed/generate")
|
||||
def generate_m3u_subscribed():
|
||||
"""Force regenerate subscribed M3U playlist"""
|
||||
try:
|
||||
# Clear cache and regenerate
|
||||
cache_file = "playlist_subscribed.m3u"
|
||||
service.vfs.delete(cache_file) # Delete if exists
|
||||
|
||||
# Call the subscribed M3U endpoint which will regenerate
|
||||
return get_m3u_subscribed()
|
||||
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/m3u/subscribed/generate: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
@@ -0,0 +1,812 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Provider-related route handlers
|
||||
"""
|
||||
|
||||
from bottle import request, response
|
||||
from streaming_providers.base.utils import logger
|
||||
|
||||
|
||||
def setup_provider_routes(app, manager, service):
|
||||
"""Setup provider-related routes"""
|
||||
|
||||
@app.route("/api/providers")
|
||||
def list_providers():
|
||||
try:
|
||||
# Get metadata for ALL providers (enabled + disabled)
|
||||
all_metadata = manager.get_all_providers_metadata()
|
||||
|
||||
# For backward compatibility, also get details for enabled providers
|
||||
enabled_providers = []
|
||||
for metadata in all_metadata:
|
||||
if metadata["enabled"] and metadata["instance_ready"]:
|
||||
provider_instance = manager.get_provider(metadata["name"])
|
||||
if provider_instance:
|
||||
# Get detailed auth info from instance
|
||||
supported_auth_types = getattr(
|
||||
provider_instance, "supported_auth_types", []
|
||||
)
|
||||
preferred_auth_type = getattr(
|
||||
provider_instance, "preferred_auth_type", "unknown"
|
||||
)
|
||||
requires_stored_credentials = getattr(
|
||||
provider_instance, "requires_stored_credentials", False
|
||||
)
|
||||
|
||||
# Check specific auth type needs
|
||||
needs_user_creds = "user_credentials" in supported_auth_types
|
||||
needs_client_creds = (
|
||||
"client_credentials" in supported_auth_types
|
||||
)
|
||||
is_network_based = "network_based" in supported_auth_types
|
||||
is_anonymous = "anonymous" in supported_auth_types
|
||||
uses_device_reg = "device_registration" in supported_auth_types
|
||||
uses_embedded = "embedded_client" in supported_auth_types
|
||||
|
||||
provider_details = {
|
||||
"name": metadata["name"],
|
||||
"label": metadata["label"],
|
||||
"logo": metadata["logo"],
|
||||
"country": metadata["country"],
|
||||
# Core authentication properties
|
||||
"auth": {
|
||||
"supported_auth_types": supported_auth_types,
|
||||
"preferred_auth_type": preferred_auth_type,
|
||||
"requires_stored_credentials": requires_stored_credentials,
|
||||
# Specific auth type flags for easy UI decisions
|
||||
"needs_user_credentials": needs_user_creds,
|
||||
"needs_client_credentials": needs_client_creds,
|
||||
"is_network_based": is_network_based,
|
||||
"is_anonymous": is_anonymous,
|
||||
"uses_device_registration": uses_device_reg,
|
||||
"uses_embedded_client": uses_embedded,
|
||||
# Derived summary for UI
|
||||
"needs_user_input": needs_user_creds or uses_device_reg,
|
||||
"needs_configuration": needs_user_creds
|
||||
or needs_client_creds,
|
||||
"is_automatic": is_network_based
|
||||
or is_anonymous
|
||||
or uses_embedded,
|
||||
},
|
||||
# Token properties
|
||||
"primary_token_scope": getattr(
|
||||
provider_instance, "primary_token_scope", None
|
||||
),
|
||||
"token_scopes": getattr(
|
||||
provider_instance, "token_scopes", []
|
||||
),
|
||||
# Metadata fields
|
||||
"enabled": metadata["enabled"],
|
||||
"instance_ready": metadata["instance_ready"],
|
||||
"requires_credentials": metadata["requires_credentials"],
|
||||
}
|
||||
enabled_providers.append(provider_details)
|
||||
|
||||
return {
|
||||
"providers": enabled_providers,
|
||||
"all_providers": all_metadata, # NEW: Include all providers metadata
|
||||
"default_country": service.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)}
|
||||
|
||||
@app.route("/api/providers/<provider>/channels")
|
||||
def get_channels(provider):
|
||||
try:
|
||||
channels = manager.get_channels(
|
||||
provider_name=provider,
|
||||
fetch_manifests=request.query.get("fetch_manifests", "false").lower()
|
||||
== "true",
|
||||
country=request.query.get("country"),
|
||||
)
|
||||
|
||||
# Get provider instance to check catchup support
|
||||
provider_instance = manager.get_provider(provider)
|
||||
provider_catchup_hours = getattr(
|
||||
provider_instance, "catchup_window", 0
|
||||
) # CHANGE
|
||||
|
||||
# Build channel list with catchup info
|
||||
channels_data = []
|
||||
for c in channels:
|
||||
channel_dict = c.to_dict()
|
||||
|
||||
# Add catchup hours - use channel-specific if available, else provider default
|
||||
if hasattr(c, "catchup_hours"): # CHANGE
|
||||
channel_dict["CatchupHours"] = c.catchup_hours # CHANGE
|
||||
else:
|
||||
channel_dict["CatchupHours"] = provider_catchup_hours # CHANGE
|
||||
|
||||
channels_data.append(channel_dict)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"country": provider_instance.country if provider_instance else "DE",
|
||||
"catchup_window_hours": provider_catchup_hours, # CHANGE
|
||||
"channels": channels_data,
|
||||
}
|
||||
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)}
|
||||
|
||||
@app.route("/api/providers/<provider>/auth/status")
|
||||
def get_provider_auth_status(provider):
|
||||
"""Get authentication status from provider itself"""
|
||||
try:
|
||||
# Get provider instance
|
||||
provider_instance = manager.get_provider(provider)
|
||||
if not provider_instance:
|
||||
response.status = 404
|
||||
return {"error": f"Provider {provider} not found"}
|
||||
|
||||
# Get SettingsManager
|
||||
settings_manager = service._get_settings_manager()
|
||||
if not settings_manager:
|
||||
response.status = 500
|
||||
return {"error": "Settings manager not available"}
|
||||
|
||||
# Import and use new auth system
|
||||
from streaming_providers.providers.auth_context import AuthContext
|
||||
|
||||
try:
|
||||
auth_context = AuthContext(settings_manager)
|
||||
auth_status = provider_instance.get_auth_status(auth_context)
|
||||
return auth_status.to_dict()
|
||||
except AttributeError as attr_err:
|
||||
logger.error(
|
||||
f"Provider {provider} missing required auth property: {attr_err}"
|
||||
)
|
||||
response.status = 501 # Not Implemented
|
||||
return {
|
||||
"error": f"Provider {provider} does not fully implement auth status",
|
||||
"details": str(attr_err),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting auth status: {e}", exc_info=True)
|
||||
response.status = 500
|
||||
return {"error": str(e)}
|
||||
|
||||
except ImportError as import_error:
|
||||
# This happens during development if modules not created yet
|
||||
logger.warning(f"Auth modules not available: {import_error}")
|
||||
return {
|
||||
"provider": provider,
|
||||
"auth_state": "not_implemented",
|
||||
"is_ready": False,
|
||||
"message": "New auth system in development",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error getting auth status for {provider}: {e}", exc_info=True
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/credentials", method="GET")
|
||||
def get_provider_credentials(provider):
|
||||
"""
|
||||
GET: Retrieve current credentials (masked for security)
|
||||
|
||||
Example: GET /api/providers/joyn/credentials
|
||||
Returns: {
|
||||
"has_credentials": true,
|
||||
"credential_type": "user_password",
|
||||
"username_masked": "us***@example.com",
|
||||
"username": "user@example.com" # Note: only included for pre-fill with user consent
|
||||
}
|
||||
"""
|
||||
try:
|
||||
settings_manager = service._get_settings_manager()
|
||||
|
||||
# Parse provider and country
|
||||
provider_name, country = settings_manager.parse_provider_country(provider)
|
||||
|
||||
# Get credentials
|
||||
credentials = settings_manager.get_provider_credentials(
|
||||
provider_name, country
|
||||
)
|
||||
|
||||
response_data = {
|
||||
"provider": provider,
|
||||
"has_credentials": credentials is not None,
|
||||
"credential_type": None,
|
||||
"username_masked": None,
|
||||
"username": None, # We'll include this only if user explicitly allows
|
||||
}
|
||||
|
||||
if credentials:
|
||||
response_data["credential_type"] = credentials.credential_type
|
||||
response_data["is_valid"] = credentials.validate()
|
||||
|
||||
# Get username if it exists (for user_password credentials)
|
||||
if hasattr(credentials, "username") and credentials.username:
|
||||
username = credentials.username
|
||||
|
||||
# Create masked version for display
|
||||
if "@" in username: # Email address
|
||||
parts = username.split("@")
|
||||
if len(parts[0]) > 2:
|
||||
masked = parts[0][:2] + "***@" + parts[1]
|
||||
else:
|
||||
masked = "***@" + parts[1]
|
||||
else: # Username
|
||||
if len(username) > 4:
|
||||
masked = username[:2] + "***" + username[-2:]
|
||||
else:
|
||||
masked = "***"
|
||||
|
||||
response_data["username_masked"] = masked
|
||||
|
||||
# For pre-filling forms (security consideration - you can omit this)
|
||||
# Only include if you trust your frontend and have HTTPS
|
||||
response_data["username"] = username
|
||||
|
||||
# Log for debugging (remove in production)
|
||||
logger.debug(
|
||||
f"GET credentials for {provider}: type={credentials.credential_type}, has_username={hasattr(credentials, 'username')}"
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"GET credentials error for {provider}: {e}", exc_info=True)
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/credentials", method="POST")
|
||||
def save_provider_credentials(provider):
|
||||
"""
|
||||
Save credentials for a provider via API
|
||||
|
||||
Accepts JSON body with credentials:
|
||||
- User/password: {"username": "...", "password": "..."}
|
||||
- For updates: {"password": "..."} (keep existing username)
|
||||
"""
|
||||
try:
|
||||
# Parse JSON body
|
||||
try:
|
||||
credentials_data = request.json
|
||||
logger.debug(
|
||||
f"Received credentials data for {provider}: {credentials_data}"
|
||||
)
|
||||
except Exception as json_err:
|
||||
logger.error(f"Invalid JSON in request body: {json_err}")
|
||||
response.status = 400
|
||||
return {"error": "Invalid JSON in request body"}
|
||||
|
||||
if not credentials_data:
|
||||
logger.error("No credentials data provided")
|
||||
response.status = 400
|
||||
return {"error": "Request body must contain credentials data"}
|
||||
|
||||
# Validate it's a dictionary
|
||||
if not isinstance(credentials_data, dict):
|
||||
logger.error(
|
||||
f"Credentials data is not a dict: {type(credentials_data)}"
|
||||
)
|
||||
response.status = 400
|
||||
return {"error": "Credentials data must be a JSON object"}
|
||||
|
||||
# Get settings manager
|
||||
settings_manager = service._get_settings_manager()
|
||||
|
||||
# Parse provider and country
|
||||
provider_name, country = settings_manager.parse_provider_country(provider)
|
||||
|
||||
# Check if we have existing credentials (for partial updates)
|
||||
existing_credentials = settings_manager.get_provider_credentials(
|
||||
provider_name, country
|
||||
)
|
||||
|
||||
if existing_credentials and "username" not in credentials_data:
|
||||
# Partial update - keep existing username, only update password
|
||||
if hasattr(existing_credentials, "username"):
|
||||
credentials_data["username"] = existing_credentials.username
|
||||
else:
|
||||
response.status = 400
|
||||
return {
|
||||
"error": "Cannot update - existing credentials do not have username"
|
||||
}
|
||||
|
||||
# Save credentials
|
||||
success, message = settings_manager.save_provider_credentials_from_api(
|
||||
provider, credentials_data
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Save result for {provider}: success={success}, message={message}"
|
||||
)
|
||||
|
||||
if success:
|
||||
# Reinitialize provider to pick up new credentials
|
||||
reinit_success = manager.reinitialize_provider(provider)
|
||||
if not reinit_success:
|
||||
logger.warning(
|
||||
f"Failed to reinitialize provider '{provider}' after credential change"
|
||||
)
|
||||
|
||||
response.status = 200
|
||||
response.content_type = "application/json; charset=utf-8"
|
||||
return {
|
||||
"success": True,
|
||||
"provider": provider,
|
||||
"message": message,
|
||||
"action": "updated" if existing_credentials else "created",
|
||||
"reinitialized": reinit_success,
|
||||
}
|
||||
else:
|
||||
# Determine appropriate status code
|
||||
if "not registered" in message.lower():
|
||||
response.status = 404
|
||||
elif (
|
||||
"invalid" in message.lower()
|
||||
or "validation failed" in message.lower()
|
||||
):
|
||||
response.status = 400
|
||||
else:
|
||||
response.status = 500
|
||||
|
||||
return {"error": message}
|
||||
|
||||
except Exception as api_err:
|
||||
logger.error(
|
||||
f"API Error in POST /api/providers/{provider}/credentials: {str(api_err)}",
|
||||
exc_info=True,
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/credentials", method="DELETE")
|
||||
def delete_provider_credentials(provider):
|
||||
"""
|
||||
Delete credentials for a provider via API
|
||||
|
||||
Example: DELETE /api/providers/joyn_de/credentials
|
||||
"""
|
||||
try:
|
||||
settings_manager = service._get_settings_manager()
|
||||
success, message = settings_manager.delete_provider_credentials_from_api(
|
||||
provider
|
||||
)
|
||||
|
||||
if success:
|
||||
# Reinitialize provider to clear any cached authentication
|
||||
reinit_success = manager.reinitialize_provider(provider)
|
||||
if not reinit_success:
|
||||
logger.warning(
|
||||
f"Failed to reinitialize provider '{provider}' after credential deletion"
|
||||
)
|
||||
|
||||
response.status = 200
|
||||
response.content_type = "application/json; charset=utf-8"
|
||||
return {
|
||||
"success": True,
|
||||
"provider": provider,
|
||||
"message": message,
|
||||
"reinitialized": reinit_success,
|
||||
}
|
||||
else:
|
||||
# Determine appropriate status code
|
||||
if "not registered" in message.lower():
|
||||
response.status = 404
|
||||
else:
|
||||
response.status = 500
|
||||
|
||||
return {"error": message}
|
||||
|
||||
except Exception as api_err:
|
||||
logger.error(
|
||||
f"API Error in DELETE /api/providers/{provider}/credentials: {str(api_err)}"
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/proxy", method="GET")
|
||||
def get_provider_proxy(provider):
|
||||
"""
|
||||
Get current proxy configuration for a provider
|
||||
|
||||
Example: GET /api/providers/joyn/proxy
|
||||
"""
|
||||
try:
|
||||
settings_manager = service._get_settings_manager()
|
||||
|
||||
# Parse provider and country
|
||||
provider_name, country = settings_manager.parse_provider_country(provider)
|
||||
|
||||
# Get proxy config
|
||||
proxy_config = settings_manager.get_provider_proxy(provider_name, country)
|
||||
|
||||
if proxy_config:
|
||||
return {
|
||||
"success": True,
|
||||
"provider": provider,
|
||||
"proxy_config": (
|
||||
proxy_config.to_dict()
|
||||
if hasattr(proxy_config, "to_dict")
|
||||
else proxy_config
|
||||
),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"provider": provider,
|
||||
"proxy_config": None,
|
||||
"message": "No proxy configuration found",
|
||||
}
|
||||
|
||||
except Exception as api_err:
|
||||
logger.error(
|
||||
f"API Error in GET /api/providers/{provider}/proxy: {str(api_err)}"
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/proxy", method="POST")
|
||||
def save_provider_proxy(provider):
|
||||
"""
|
||||
Save proxy configuration for a provider via API
|
||||
|
||||
Accepts JSON body with proxy configuration:
|
||||
Required: {"host": "proxy.example.com", "port": 8080}
|
||||
Optional: {
|
||||
"proxy_type": "http", # http, https, socks4, socks5
|
||||
"username": "proxyuser",
|
||||
"password": "proxypass",
|
||||
"timeout": 30,
|
||||
"verify_ssl": true,
|
||||
"scope": {
|
||||
"api_calls": true,
|
||||
"authentication": true,
|
||||
"manifests": true,
|
||||
"license": true,
|
||||
"all": true
|
||||
}
|
||||
}
|
||||
|
||||
Example: POST /api/providers/joyn_de/proxy
|
||||
Body: {"host": "proxy.example.com", "port": 8080}
|
||||
"""
|
||||
try:
|
||||
# Parse JSON body
|
||||
try:
|
||||
proxy_data = request.json
|
||||
except Exception as json_err:
|
||||
logger.error(f"Invalid JSON in request body: {json_err}")
|
||||
response.status = 400
|
||||
return {"error": "Invalid JSON in request body"}
|
||||
|
||||
if not proxy_data:
|
||||
response.status = 400
|
||||
return {"error": "Request body must contain proxy configuration"}
|
||||
|
||||
# Validate it's a dictionary
|
||||
if not isinstance(proxy_data, dict):
|
||||
response.status = 400
|
||||
return {"error": "Proxy data must be a JSON object"}
|
||||
|
||||
settings_manager = service._get_settings_manager()
|
||||
success, message = settings_manager.save_provider_proxy_from_api(
|
||||
provider, proxy_data
|
||||
)
|
||||
|
||||
if success:
|
||||
# Reinitialize provider to pick up new proxy configuration
|
||||
reinit_success = manager.reinitialize_provider(provider)
|
||||
if not reinit_success:
|
||||
logger.warning(
|
||||
f"Failed to reinitialize provider '{provider}' after proxy change"
|
||||
)
|
||||
|
||||
response.status = 200
|
||||
response.content_type = "application/json; charset=utf-8"
|
||||
return {
|
||||
"success": True,
|
||||
"provider": provider,
|
||||
"message": message,
|
||||
"reinitialized": reinit_success,
|
||||
}
|
||||
else:
|
||||
# Determine appropriate status code
|
||||
if "not registered" in message.lower():
|
||||
response.status = 404
|
||||
elif (
|
||||
"invalid" in message.lower()
|
||||
or "validation failed" in message.lower()
|
||||
):
|
||||
response.status = 400
|
||||
else:
|
||||
response.status = 500
|
||||
|
||||
return {"error": message}
|
||||
|
||||
except Exception as api_err:
|
||||
logger.error(
|
||||
f"API Error in POST /api/providers/{provider}/proxy: {str(api_err)}"
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/proxy", method="DELETE")
|
||||
def delete_provider_proxy(provider):
|
||||
"""
|
||||
Delete proxy configuration for a provider via API
|
||||
|
||||
Example: DELETE /api/providers/joyn_de/proxy
|
||||
"""
|
||||
try:
|
||||
settings_manager = service._get_settings_manager()
|
||||
success, message = settings_manager.delete_provider_proxy_from_api(provider)
|
||||
|
||||
if success:
|
||||
# Reinitialize provider to remove proxy configuration
|
||||
reinit_success = manager.reinitialize_provider(provider)
|
||||
if not reinit_success:
|
||||
logger.warning(
|
||||
f"Failed to reinitialize provider '{provider}' after proxy deletion"
|
||||
)
|
||||
|
||||
response.status = 200
|
||||
response.content_type = "application/json; charset=utf-8"
|
||||
return {
|
||||
"success": True,
|
||||
"provider": provider,
|
||||
"message": message,
|
||||
"reinitialized": reinit_success,
|
||||
}
|
||||
else:
|
||||
# Determine appropriate status code
|
||||
if "not registered" in message.lower():
|
||||
response.status = 404
|
||||
else:
|
||||
response.status = 500
|
||||
|
||||
return {"error": message}
|
||||
|
||||
except Exception as api_err:
|
||||
logger.error(
|
||||
f"API Error in DELETE /api/providers/{provider}/proxy: {str(api_err)}"
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/reinitialize", method="POST")
|
||||
def reinitialize_provider(provider):
|
||||
"""
|
||||
Manually reinitialize a provider (e.g., after external configuration changes)
|
||||
|
||||
Example: POST /api/providers/joyn_de/reinitialize
|
||||
"""
|
||||
try:
|
||||
success = manager.reinitialize_provider(provider)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"provider": provider,
|
||||
"message": f"Provider {provider} reinitialized successfully",
|
||||
}
|
||||
else:
|
||||
response.status = 500
|
||||
return {
|
||||
"success": False,
|
||||
"provider": provider,
|
||||
"message": f"Failed to reinitialize provider {provider}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reinitializing provider {provider}: {e}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(e)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/subscription")
|
||||
def get_provider_subscription(provider):
|
||||
"""Get subscription status for a provider"""
|
||||
try:
|
||||
subscription = manager.get_subscription_status(provider)
|
||||
|
||||
if subscription:
|
||||
return {"success": True, "subscription": subscription.to_dict()}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"subscription": None,
|
||||
"message": "No subscription information available",
|
||||
}
|
||||
|
||||
except ValueError as val_err:
|
||||
response.status = 404
|
||||
return {"error": str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in subscription endpoint: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/channels/subscribed")
|
||||
def get_subscribed_channels(provider):
|
||||
"""Get channels the user is subscribed to for a provider"""
|
||||
try:
|
||||
channels = manager.get_subscribed_channels(provider)
|
||||
|
||||
# Get provider instance for additional info
|
||||
provider_instance = manager.get_provider(provider)
|
||||
provider_catchup_hours = (
|
||||
getattr(provider_instance, "catchup_window", 0)
|
||||
if provider_instance
|
||||
else 0
|
||||
)
|
||||
|
||||
channels_data = []
|
||||
for c in channels:
|
||||
channel_dict = c.to_dict()
|
||||
|
||||
# Add catchup hours
|
||||
if hasattr(c, "catchup_hours"):
|
||||
channel_dict["CatchupHours"] = c.catchup_hours
|
||||
else:
|
||||
channel_dict["CatchupHours"] = provider_catchup_hours
|
||||
|
||||
channels_data.append(channel_dict)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"channels": channels_data,
|
||||
"count": len(channels_data),
|
||||
"is_filtered": True, # Indicates subscription filtering was applied
|
||||
}
|
||||
|
||||
except ValueError as val_err:
|
||||
response.status = 404
|
||||
return {"error": str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in subscribed channels endpoint: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/packages")
|
||||
def get_provider_packages(provider):
|
||||
"""Get available subscription packages for a provider"""
|
||||
try:
|
||||
packages = manager.get_available_packages(provider)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"provider": provider,
|
||||
"packages": [
|
||||
{
|
||||
"package_id": pkg.package_id,
|
||||
"name": pkg.name,
|
||||
"description": pkg.description,
|
||||
"price_info": pkg.price_info,
|
||||
"channel_count": pkg.channel_count,
|
||||
"metadata": pkg.metadata,
|
||||
}
|
||||
for pkg in packages
|
||||
],
|
||||
"count": len(packages),
|
||||
}
|
||||
|
||||
except ValueError as val_err:
|
||||
response.status = 404
|
||||
return {"error": str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in packages endpoint: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/providers/channels/subscribed")
|
||||
def get_all_subscribed_channels():
|
||||
"""Get all subscribed channels across all providers"""
|
||||
try:
|
||||
all_subscribed = {}
|
||||
provider_list = manager.list_providers()
|
||||
|
||||
for provider in provider_list:
|
||||
try:
|
||||
channels = manager.get_subscribed_channels(provider)
|
||||
if channels:
|
||||
all_subscribed[provider] = [c.to_dict() for c in channels]
|
||||
except Exception as provider_err:
|
||||
logger.warning(
|
||||
f"Could not get subscribed channels for {provider}: {provider_err}"
|
||||
)
|
||||
continue
|
||||
|
||||
total_channels = sum(len(channels) for channels in all_subscribed.values())
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"providers": list(all_subscribed.keys()),
|
||||
"channels_by_provider": all_subscribed,
|
||||
"total_channels": total_channels,
|
||||
"provider_count": len(all_subscribed),
|
||||
}
|
||||
|
||||
except Exception as api_err:
|
||||
logger.error(
|
||||
f"API Error in all subscribed channels endpoint: {str(api_err)}"
|
||||
)
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/enabled", method="GET")
|
||||
def get_provider_enabled(provider):
|
||||
"""Get enabled status for specific provider"""
|
||||
try:
|
||||
# Validate provider exists
|
||||
if not manager.get_provider(provider):
|
||||
response.status = 404
|
||||
return {"error": f"Provider {provider} not found"}
|
||||
|
||||
from streaming_providers.base.settings.provider_enable_manager import (
|
||||
ProviderEnableManager,
|
||||
)
|
||||
|
||||
enable_manager = ProviderEnableManager()
|
||||
status = enable_manager.is_provider_enabled(provider)
|
||||
source = enable_manager.get_enabled_source(provider)
|
||||
|
||||
# FIX: Convert enum to string value
|
||||
source_value = source.value if hasattr(source, "value") else str(source)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"provider": provider,
|
||||
"enabled": status,
|
||||
"source": source_value, # Now a string
|
||||
"can_modify": source != "kodi",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting enabled status for {provider}: {e}")
|
||||
response.status = 500
|
||||
return {"error": str(e)}
|
||||
|
||||
@app.route("/api/providers/<provider>/enabled", method="POST")
|
||||
def set_provider_enabled(provider):
|
||||
"""Set enabled status for provider"""
|
||||
try:
|
||||
# Parse request
|
||||
try:
|
||||
data = request.json
|
||||
if not data or "enabled" not in data:
|
||||
response.status = 400
|
||||
return {"error": 'Missing "enabled" field'}
|
||||
|
||||
enabled = bool(data["enabled"])
|
||||
except ValueError:
|
||||
response.status = 400
|
||||
return {"error": "Invalid JSON"}
|
||||
|
||||
# Use the new manager method
|
||||
success = manager.set_provider_enabled(provider, enabled)
|
||||
|
||||
if success:
|
||||
# Get updated metadata
|
||||
metadata = None
|
||||
all_metadata = manager.get_all_providers_metadata()
|
||||
for md in all_metadata:
|
||||
if md["name"] == provider:
|
||||
metadata = md
|
||||
break
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"provider": provider,
|
||||
"enabled": enabled,
|
||||
"metadata": metadata,
|
||||
"message": f'Provider {provider} {"enabled" if enabled else "disabled"}',
|
||||
}
|
||||
else:
|
||||
response.status = 500
|
||||
return {
|
||||
"error": f'Failed to {"enable" if enabled else "disable"} provider {provider}'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting enabled status for {provider}: {e}")
|
||||
response.status = 500
|
||||
return {"error": str(e)}
|
||||
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stream and manifest route handlers
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from bottle import HTTPResponse, redirect, request, response
|
||||
from streaming_providers.base.utils import logger
|
||||
|
||||
|
||||
def setup_stream_routes(app, manager, service):
|
||||
"""Setup stream and manifest-related routes"""
|
||||
|
||||
@app.route("/api/providers/<provider>/channels/<channel_id>/manifest")
|
||||
def get_channel_manifest(provider, channel_id):
|
||||
"""
|
||||
Get channel manifest. Always returns JSON with manifest_url pointing to stream endpoint.
|
||||
"""
|
||||
try:
|
||||
# Build the stream URL (which will handle both proxy and non-proxy)
|
||||
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
|
||||
stream_url = (
|
||||
f"{base_url}/api/providers/{provider}/channels/{channel_id}/stream"
|
||||
)
|
||||
|
||||
# Add country parameter if provided
|
||||
country = request.query.get("country")
|
||||
if country:
|
||||
stream_url += f"?country={country}"
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"channel_id": channel_id,
|
||||
"manifest_url": stream_url, # Always point to /stream endpoint
|
||||
}
|
||||
|
||||
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)}"}
|
||||
|
||||
@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.
|
||||
Supports both live and catchup streaming.
|
||||
"""
|
||||
try:
|
||||
# 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 = manager.get_provider(provider)
|
||||
catchup_hours = getattr(
|
||||
provider_instance, "catchup_window", 0
|
||||
) # Now in hours
|
||||
|
||||
if catchup_hours == 0:
|
||||
response.status = 400
|
||||
return {"error": f'Catchup not supported for provider "{provider}"'}
|
||||
|
||||
# Validate time is within catchup window (in HOURS)
|
||||
import time
|
||||
|
||||
now = int(time.time())
|
||||
max_age_seconds = catchup_hours * 3600 # Hours to seconds
|
||||
|
||||
if (now - start_time_int) > max_age_seconds:
|
||||
response.status = 400
|
||||
return {
|
||||
"error": f"Content outside catchup window (max {catchup_hours} hours)"
|
||||
}
|
||||
|
||||
# Check if provider needs proxy for catchup
|
||||
if manager.needs_proxy(provider):
|
||||
# Proxy mode: return rewritten MPD content directly
|
||||
return service._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 = 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:
|
||||
# Live stream - existing logic
|
||||
if manager.needs_proxy(provider):
|
||||
return service._get_proxied_manifest(provider, channel_id)
|
||||
else:
|
||||
manifest_url = 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}"'
|
||||
}
|
||||
|
||||
logger.debug(f"Redirecting to manifest: {manifest_url}")
|
||||
redirect(manifest_url)
|
||||
|
||||
except HTTPResponse:
|
||||
raise
|
||||
except ValueError as 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 stream: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/channels/<channel_id>/epg")
|
||||
def get_channel_epg(provider, channel_id):
|
||||
try:
|
||||
# Parse optional parameters
|
||||
kwargs = {"country": request.query.get("country")}
|
||||
|
||||
from datetime import timezone
|
||||
|
||||
# Handle start_time - can be Unix timestamp (from Kodi) or datetime
|
||||
if request.query.get("start_time"):
|
||||
start_time_str = request.query.get("start_time")
|
||||
try:
|
||||
# Try to parse as Unix timestamp (integer from Kodi PVR)
|
||||
start_time_int = int(start_time_str)
|
||||
kwargs["start_time"] = datetime.fromtimestamp(
|
||||
start_time_int, tz=timezone.utc
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
# Try to parse as ISO format string (for manual API calls)
|
||||
try:
|
||||
kwargs["start_time"] = datetime.fromisoformat(
|
||||
start_time_str.replace("Z", "+00:00")
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid start_time format: {start_time_str}")
|
||||
# Continue without start_time filter
|
||||
pass
|
||||
|
||||
# Handle end_time - can be Unix timestamp or datetime
|
||||
if request.query.get("end_time"):
|
||||
end_time_str = request.query.get("end_time")
|
||||
try:
|
||||
# Try to parse as Unix timestamp (integer from Kodi PVR)
|
||||
end_time_int = int(end_time_str)
|
||||
kwargs["end_time"] = datetime.fromtimestamp(
|
||||
end_time_int, tz=timezone.utc
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
# Try to parse as ISO format string (for manual API calls)
|
||||
try:
|
||||
kwargs["end_time"] = datetime.fromisoformat(
|
||||
end_time_str.replace("Z", "+00:00")
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid end_time format: {end_time_str}")
|
||||
# Continue without end_time filter
|
||||
pass
|
||||
|
||||
# Get EPG data from manager
|
||||
epg_data = manager.get_channel_epg(
|
||||
provider_name=provider, channel_id=channel_id, **kwargs
|
||||
)
|
||||
|
||||
# Return as JSON
|
||||
response.content_type = "application/json; charset=utf-8"
|
||||
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
|
||||
response.content_type = "application/json; charset=utf-8"
|
||||
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
|
||||
response.content_type = "application/json; charset=utf-8"
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@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 = 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)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/channels/<channel_id>/drm")
|
||||
def get_channel_drm(provider, channel_id):
|
||||
try:
|
||||
# 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 = 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 = 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 = {}
|
||||
for config in drm_configs:
|
||||
if hasattr(config, "to_dict"):
|
||||
config_dict = config.to_dict()
|
||||
else:
|
||||
config_dict = config
|
||||
merged_drm_configs.update(config_dict)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"channel_id": channel_id,
|
||||
"is_catchup": is_catchup,
|
||||
"drm_configs": merged_drm_configs,
|
||||
}
|
||||
|
||||
except ValueError as 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 DRM endpoint: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
|
||||
@app.route("/api/providers/<provider>/channels/<channel_id>/stream/decrypted")
|
||||
def get_channel_stream_decrypted(provider, channel_id):
|
||||
"""
|
||||
Returns rewritten manifest for decrypted playback via media proxy.
|
||||
This endpoint is used by the decrypted M3U playlists.
|
||||
"""
|
||||
try:
|
||||
# Check if media proxy is configured
|
||||
if not service.media_proxy_url:
|
||||
response.status = 503
|
||||
return {"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"}
|
||||
|
||||
country = request.query.get("country")
|
||||
|
||||
# Get DRM configs to extract ClearKey data
|
||||
drm_configs = manager.get_channel_drm_configs(
|
||||
provider_name=provider, channel_id=channel_id, country=country
|
||||
)
|
||||
|
||||
# Extract ClearKey data
|
||||
clearkey_data = None
|
||||
if isinstance(drm_configs, dict) and "org.w3.clearkey" in drm_configs:
|
||||
clearkey_data = drm_configs["org.w3.clearkey"]
|
||||
|
||||
if not clearkey_data:
|
||||
response.status = 400
|
||||
return {"error": f'Channel "{channel_id}" does not have ClearKey DRM'}
|
||||
|
||||
# Extract KID:Key pairs
|
||||
license_info = clearkey_data.get("license", {})
|
||||
keyids = license_info.get("keyids", {})
|
||||
|
||||
if not keyids:
|
||||
response.status = 400
|
||||
return {"error": f'No ClearKey keyids found for channel "{channel_id}"'}
|
||||
|
||||
# Get manifest (check if needs proxy)
|
||||
if manager.needs_proxy(provider):
|
||||
# Get rewritten manifest with media proxy URLs + decrypt params
|
||||
return service._get_decrypted_manifest(provider, channel_id, keyids)
|
||||
else:
|
||||
# No proxy needed - return error (decryption requires media proxy)
|
||||
response.status = 400
|
||||
return {
|
||||
"error": "Decrypted playback requires provider proxy configuration"
|
||||
}
|
||||
|
||||
except ValueError as val_err:
|
||||
logger.error(f"API Error in decrypted stream: {str(val_err)}")
|
||||
response.status = 404
|
||||
return {"error": str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in decrypted stream: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {"error": f"Internal server error: {str(api_err)}"}
|
||||
+608
-2400
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user