Files
2026-04-19 14:35:17 +02:00

859 lines
34 KiB
Python

#!/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
epg_window = getattr(provider_instance, "epg_window", (0, 0))
# 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
"epg_window": {
"past_days": epg_window[0],
"future_days": epg_window[1],
"implements_epg": epg_window != (0, 0),
},
"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)}
@app.route("/api/providers/rediscover", method="POST")
def rediscover_providers():
"""
Re-scan for new providers (e.g., newly added M3U playlists).
Accepts optional query parameter:
- country: Country code for provider discovery (default: service default_country)
Example: POST /api/providers/rediscover
Example: POST /api/providers/rediscover?country=US
Returns:
JSON with list of all discovered providers and count
"""
try:
# Get country from query param or use service default
country = request.query.get("country", service.default_country)
logger.info(f"API: Rediscovering providers for country '{country}'")
# Trigger provider rediscovery
discovered = manager.rediscover_providers(country)
# Get updated metadata
all_metadata = manager.get_all_providers_metadata()
enabled_count = sum(1 for m in all_metadata if m["enabled"])
return {
"success": True,
"message": f"Re-discovered {len(discovered)} providers ({enabled_count} enabled)",
"providers": discovered,
"count": len(discovered),
"enabled_count": enabled_count,
"country": country,
}
except Exception as api_err:
logger.error(f"API Error in POST /api/providers/rediscover: {str(api_err)}")
response.status = 500
return {"error": f"Failed to rediscover providers: {str(api_err)}"}