584 lines
21 KiB
Python
584 lines
21 KiB
Python
from fastapi import APIRouter, HTTPException, Request, Depends
|
|
import json
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
# Import the centralized config getters that handle file creation and defaults
|
|
from routes.utils.celery_config import (
|
|
get_config_params as get_main_config_params,
|
|
DEFAULT_MAIN_CONFIG,
|
|
CONFIG_FILE_PATH as MAIN_CONFIG_FILE_PATH,
|
|
)
|
|
from routes.utils.watch.manager import (
|
|
get_watch_config as get_watch_manager_config,
|
|
DEFAULT_WATCH_CONFIG,
|
|
MAIN_CONFIG_FILE_PATH as WATCH_MAIN_CONFIG_FILE_PATH,
|
|
)
|
|
|
|
# Import authentication dependencies
|
|
from routes.auth.middleware import require_admin_from_state, User
|
|
|
|
# Import credential utilities (DB-backed)
|
|
from routes.utils.credentials import list_credentials, _get_global_spotify_api_creds
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
# Flag for config change notifications
|
|
config_changed = False
|
|
last_config: dict[str, Any] = {}
|
|
|
|
# Define parameters that should trigger notification when changed
|
|
NOTIFY_PARAMETERS = [
|
|
"maxConcurrentDownloads",
|
|
"service",
|
|
"fallback",
|
|
"spotifyQuality",
|
|
"deezerQuality",
|
|
]
|
|
|
|
|
|
# Helper functions to get final merged configs (simulate save without actually saving)
|
|
def get_final_main_config(new_config_data: dict) -> dict:
|
|
"""Returns the final main config that will be saved after merging with new_config_data."""
|
|
try:
|
|
# Load current or default config
|
|
existing_config = {}
|
|
if MAIN_CONFIG_FILE_PATH.exists():
|
|
with open(MAIN_CONFIG_FILE_PATH, "r") as f_read:
|
|
existing_config = json.load(f_read)
|
|
else:
|
|
existing_config = DEFAULT_MAIN_CONFIG.copy()
|
|
|
|
# Update with new data
|
|
for key, value in new_config_data.items():
|
|
existing_config[key] = value
|
|
|
|
# Migration: unify legacy keys to camelCase
|
|
_migrate_legacy_keys_inplace(existing_config)
|
|
|
|
# Ensure all default keys are still there
|
|
for default_key, default_value in DEFAULT_MAIN_CONFIG.items():
|
|
if default_key not in existing_config:
|
|
existing_config[default_key] = default_value
|
|
|
|
return existing_config
|
|
except Exception as e:
|
|
logger.error(f"Error creating final main config: {e}", exc_info=True)
|
|
return DEFAULT_MAIN_CONFIG.copy()
|
|
|
|
|
|
def get_final_watch_config(new_watch_config_data: dict) -> dict:
|
|
"""Returns the final watch config that will be saved after merging with new_watch_config_data."""
|
|
try:
|
|
# Load current main config
|
|
main_cfg: dict = {}
|
|
if WATCH_MAIN_CONFIG_FILE_PATH.exists():
|
|
with open(WATCH_MAIN_CONFIG_FILE_PATH, "r") as f:
|
|
main_cfg = json.load(f) or {}
|
|
else:
|
|
main_cfg = DEFAULT_MAIN_CONFIG.copy()
|
|
|
|
# Get and update watch config
|
|
watch_value = main_cfg.get("watch")
|
|
current_watch = (
|
|
watch_value.copy() if isinstance(watch_value, dict) else {}
|
|
).copy()
|
|
current_watch.update(new_watch_config_data or {})
|
|
|
|
# Ensure defaults
|
|
for k, v in DEFAULT_WATCH_CONFIG.items():
|
|
if k not in current_watch:
|
|
current_watch[k] = v
|
|
|
|
return current_watch
|
|
except Exception as e:
|
|
logger.error(f"Error creating final watch config: {e}", exc_info=True)
|
|
return DEFAULT_WATCH_CONFIG.copy()
|
|
|
|
|
|
def get_final_main_config_for_watch(new_watch_config_data: dict) -> dict:
|
|
"""Returns the final main config when updating watch config."""
|
|
try:
|
|
# Load current main config
|
|
main_cfg: dict = {}
|
|
if WATCH_MAIN_CONFIG_FILE_PATH.exists():
|
|
with open(WATCH_MAIN_CONFIG_FILE_PATH, "r") as f:
|
|
main_cfg = json.load(f) or {}
|
|
else:
|
|
main_cfg = DEFAULT_MAIN_CONFIG.copy()
|
|
|
|
# Migrate legacy keys
|
|
_migrate_legacy_keys_inplace(main_cfg)
|
|
|
|
# Ensure all default keys are still there
|
|
for default_key, default_value in DEFAULT_MAIN_CONFIG.items():
|
|
if default_key not in main_cfg:
|
|
main_cfg[default_key] = default_value
|
|
|
|
return main_cfg
|
|
except Exception as e:
|
|
logger.error(f"Error creating final main config for watch: {e}", exc_info=True)
|
|
return DEFAULT_MAIN_CONFIG.copy()
|
|
|
|
|
|
# Helper function to check if credentials exist for a service
|
|
def has_credentials(service: str) -> bool:
|
|
"""Check if credentials exist for the specified service (spotify or deezer)."""
|
|
try:
|
|
if service not in ("spotify", "deezer"):
|
|
return False
|
|
|
|
account_names = list_credentials(service)
|
|
has_any_accounts = bool(account_names)
|
|
|
|
if service == "spotify":
|
|
client_id, client_secret = _get_global_spotify_api_creds()
|
|
has_global_api_creds = bool(client_id) and bool(client_secret)
|
|
return has_any_accounts and has_global_api_creds
|
|
|
|
return has_any_accounts
|
|
except Exception as e:
|
|
logger.warning(f"Error checking credentials for {service}: {e}")
|
|
return False
|
|
|
|
|
|
# Validation function for configuration consistency
|
|
def validate_config(config_data: dict, watch_config: dict = None) -> tuple[bool, str]:
|
|
"""
|
|
Validate configuration for consistency and requirements.
|
|
Returns (is_valid, error_message).
|
|
"""
|
|
try:
|
|
# Get final merged watch config for validation
|
|
if watch_config is None:
|
|
if "watch" in config_data:
|
|
watch_config = get_final_watch_config(config_data["watch"])
|
|
else:
|
|
watch_config = get_watch_config_http()
|
|
|
|
# Ensure realTimeMultiplier is a valid integer in range 0..10 if provided
|
|
if "realTimeMultiplier" in config_data or "real_time_multiplier" in config_data:
|
|
key = (
|
|
"realTimeMultiplier"
|
|
if "realTimeMultiplier" in config_data
|
|
else "real_time_multiplier"
|
|
)
|
|
val = config_data.get(key)
|
|
if isinstance(val, bool):
|
|
return False, "realTimeMultiplier must be an integer between 0 and 10."
|
|
try:
|
|
ival = int(val)
|
|
except Exception:
|
|
return False, "realTimeMultiplier must be an integer between 0 and 10."
|
|
if ival < 0 or ival > 10:
|
|
return False, "realTimeMultiplier must be between 0 and 10."
|
|
# Normalize to camelCase in the working dict so save_config writes it
|
|
if key == "real_time_multiplier":
|
|
config_data["realTimeMultiplier"] = ival
|
|
else:
|
|
config_data["realTimeMultiplier"] = ival
|
|
|
|
# Check if fallback is enabled but missing required accounts
|
|
if config_data.get("fallback", False):
|
|
has_spotify = has_credentials("spotify")
|
|
has_deezer = has_credentials("deezer")
|
|
|
|
if not has_spotify or not has_deezer:
|
|
missing_services = []
|
|
if not has_spotify:
|
|
missing_services.append("Spotify")
|
|
if not has_deezer:
|
|
missing_services.append("Deezer")
|
|
|
|
return (
|
|
False,
|
|
f"Download Fallback requires accounts to be configured for both services. Missing: {', '.join(missing_services)}. Configure accounts before enabling fallback.",
|
|
)
|
|
|
|
# Check if watch is enabled but no download methods are available
|
|
if watch_config.get("enabled", False):
|
|
real_time = config_data.get("realTime", False)
|
|
fallback = config_data.get("fallback", False)
|
|
|
|
if not real_time and not fallback:
|
|
return (
|
|
False,
|
|
"Watch functionality requires either Real-time downloading or Download Fallback to be enabled.",
|
|
)
|
|
|
|
return True, ""
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error validating configuration: {e}", exc_info=True)
|
|
return False, f"Configuration validation error: {str(e)}"
|
|
|
|
|
|
def validate_watch_config(
|
|
watch_data: dict, main_config: dict = None
|
|
) -> tuple[bool, str]:
|
|
"""
|
|
Validate watch configuration for consistency and requirements.
|
|
Returns (is_valid, error_message).
|
|
"""
|
|
try:
|
|
# Get final merged main config for validation
|
|
if main_config is None:
|
|
main_config = get_final_main_config_for_watch(watch_data)
|
|
|
|
# Check if trying to enable watch without download methods
|
|
if watch_data.get("enabled", False):
|
|
real_time = main_config.get("realTime", False)
|
|
fallback = main_config.get("fallback", False)
|
|
|
|
if not real_time and not fallback:
|
|
return (
|
|
False,
|
|
"Cannot enable watch: either Real-time downloading or Download Fallback must be enabled in download settings.",
|
|
)
|
|
|
|
# If fallback is enabled, check for required accounts
|
|
if fallback:
|
|
has_spotify = has_credentials("spotify")
|
|
has_deezer = has_credentials("deezer")
|
|
|
|
if not has_spotify or not has_deezer:
|
|
missing_services = []
|
|
if not has_spotify:
|
|
missing_services.append("Spotify")
|
|
if not has_deezer:
|
|
missing_services.append("Deezer")
|
|
|
|
return (
|
|
False,
|
|
f"Cannot enable watch with fallback: missing accounts for {', '.join(missing_services)}. Configure accounts before enabling watch.",
|
|
)
|
|
|
|
return True, ""
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error validating watch configuration: {e}", exc_info=True)
|
|
return False, f"Watch configuration validation error: {str(e)}"
|
|
|
|
|
|
# Helper to get main config (uses the one from celery_config)
|
|
def get_config():
|
|
"""Retrieves the main configuration, creating it with defaults if necessary."""
|
|
return get_main_config_params()
|
|
|
|
|
|
def _migrate_legacy_keys_inplace(cfg: dict) -> bool:
|
|
"""Migrate legacy snake_case keys in the main config to camelCase. Returns True if modified."""
|
|
legacy_map = {
|
|
"tracknum_padding": "tracknumPadding",
|
|
"save_cover": "saveCover",
|
|
"retry_delay_increase": "retryDelayIncrease",
|
|
"artist_separator": "artistSeparator",
|
|
"recursive_quality": "recursiveQuality",
|
|
"spotify_metadata": "spotifyMetadata",
|
|
"real_time_multiplier": "realTimeMultiplier",
|
|
}
|
|
modified = False
|
|
for legacy, camel in legacy_map.items():
|
|
if legacy in cfg and camel not in cfg:
|
|
cfg[camel] = cfg.pop(legacy)
|
|
modified = True
|
|
# Ensure watch block exists and migrate inside watch defaults handled in manager.get_watch_config
|
|
if "watch" not in cfg or not isinstance(cfg.get("watch"), dict):
|
|
cfg["watch"] = DEFAULT_WATCH_CONFIG.copy()
|
|
modified = True
|
|
return modified
|
|
|
|
|
|
def save_config(config_data):
|
|
"""Saves the main configuration data to main.json."""
|
|
try:
|
|
MAIN_CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
# Load current or default config
|
|
existing_config = {}
|
|
if MAIN_CONFIG_FILE_PATH.exists():
|
|
with open(MAIN_CONFIG_FILE_PATH, "r") as f_read:
|
|
existing_config = json.load(f_read)
|
|
else: # Should be rare if get_config_params was called
|
|
existing_config = DEFAULT_MAIN_CONFIG.copy()
|
|
|
|
# Update with new data
|
|
for key, value in config_data.items():
|
|
existing_config[key] = value
|
|
|
|
# Migration: unify legacy keys to camelCase
|
|
if _migrate_legacy_keys_inplace(existing_config):
|
|
logger.info("Migrated legacy config keys to camelCase.")
|
|
|
|
# Ensure all default keys are still there
|
|
for default_key, default_value in DEFAULT_MAIN_CONFIG.items():
|
|
if default_key not in existing_config:
|
|
existing_config[default_key] = default_value
|
|
|
|
with open(MAIN_CONFIG_FILE_PATH, "w") as f:
|
|
json.dump(existing_config, f, indent=4)
|
|
logger.info(f"Main configuration saved to {MAIN_CONFIG_FILE_PATH}")
|
|
return True, None
|
|
except Exception as e:
|
|
logger.error(f"Error saving main configuration: {e}", exc_info=True)
|
|
return False, str(e)
|
|
|
|
|
|
def get_watch_config_http():
|
|
"""Retrieves the watch configuration from main.json watch key."""
|
|
return get_watch_manager_config()
|
|
|
|
|
|
def save_watch_config_http(watch_config_data):
|
|
"""Saves the watch configuration data to the 'watch' key in main.json."""
|
|
try:
|
|
WATCH_MAIN_CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
if WATCH_MAIN_CONFIG_FILE_PATH.exists():
|
|
with open(WATCH_MAIN_CONFIG_FILE_PATH, "r") as f:
|
|
main_cfg = json.load(f) or {}
|
|
else:
|
|
main_cfg = DEFAULT_MAIN_CONFIG.copy()
|
|
current_watch = (main_cfg.get("watch") or {}).copy()
|
|
current_watch.update(watch_config_data or {})
|
|
# Ensure defaults
|
|
for k, v in DEFAULT_WATCH_CONFIG.items():
|
|
if k not in current_watch:
|
|
current_watch[k] = v
|
|
main_cfg["watch"] = current_watch
|
|
# Migrate legacy main keys as well
|
|
_migrate_legacy_keys_inplace(main_cfg)
|
|
with open(WATCH_MAIN_CONFIG_FILE_PATH, "w") as f:
|
|
json.dump(main_cfg, f, indent=4)
|
|
logger.info("Watch configuration updated in main.json under 'watch'.")
|
|
return True, None
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error saving watch configuration to main.json: {e}", exc_info=True
|
|
)
|
|
return False, str(e)
|
|
|
|
|
|
@router.get("/")
|
|
@router.get("")
|
|
async def handle_config(current_user: User = Depends(require_admin_from_state)):
|
|
"""Handles GET requests for the main configuration."""
|
|
try:
|
|
config = get_config()
|
|
return config
|
|
except Exception as e:
|
|
logger.error(f"Error in GET /config: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={"error": "Failed to retrieve configuration", "details": str(e)},
|
|
)
|
|
|
|
|
|
@router.post("/")
|
|
@router.put("/")
|
|
@router.post("")
|
|
@router.put("")
|
|
async def update_config(
|
|
request: Request, current_user: User = Depends(require_admin_from_state)
|
|
):
|
|
"""Handles POST/PUT requests to update the main configuration."""
|
|
try:
|
|
new_config = await request.json()
|
|
if not isinstance(new_config, dict):
|
|
raise HTTPException(
|
|
status_code=400, detail={"error": "Invalid config format"}
|
|
)
|
|
|
|
# Preserve the explicitFilter setting from environment
|
|
explicit_filter_env = os.environ.get("EXPLICIT_FILTER", "false").lower()
|
|
new_config["explicitFilter"] = explicit_filter_env in ("true", "1", "yes", "on")
|
|
|
|
# Validate configuration before saving
|
|
is_valid, error_message = validate_config(new_config)
|
|
if not is_valid:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"error": "Configuration validation failed",
|
|
"details": error_message,
|
|
},
|
|
)
|
|
|
|
success, error_msg = save_config(new_config)
|
|
if success:
|
|
# Return the updated config
|
|
updated_config_values = get_config()
|
|
if updated_config_values is None:
|
|
# This case should ideally not be reached if save_config succeeded
|
|
# and get_config handles errors by returning a default or None.
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={"error": "Failed to retrieve configuration after saving"},
|
|
)
|
|
|
|
return updated_config_values
|
|
else:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={
|
|
"error": "Failed to update configuration",
|
|
"details": error_msg,
|
|
},
|
|
)
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(status_code=400, detail={"error": "Invalid JSON data"})
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error in POST/PUT /config: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={"error": "Failed to update configuration", "details": str(e)},
|
|
)
|
|
|
|
|
|
@router.get("/check")
|
|
async def check_config_changes(current_user: User = Depends(require_admin_from_state)):
|
|
# This endpoint seems more related to dynamically checking if config changed
|
|
# on disk, which might not be necessary if settings are applied on restart
|
|
# or by a dedicated manager. For now, just return current config.
|
|
try:
|
|
config = get_config()
|
|
return {"message": "Current configuration retrieved.", "config": config}
|
|
except Exception as e:
|
|
logger.error(f"Error in GET /config/check: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={"error": "Failed to check configuration", "details": str(e)},
|
|
)
|
|
|
|
|
|
@router.post("/validate")
|
|
async def validate_config_endpoint(
|
|
request: Request, current_user: User = Depends(require_admin_from_state)
|
|
):
|
|
"""Validate configuration without saving it."""
|
|
try:
|
|
config_data = await request.json()
|
|
if not isinstance(config_data, dict):
|
|
raise HTTPException(
|
|
status_code=400, detail={"error": "Invalid config format"}
|
|
)
|
|
|
|
is_valid, error_message = validate_config(config_data)
|
|
|
|
return {
|
|
"valid": is_valid,
|
|
"message": "Configuration is valid" if is_valid else error_message,
|
|
"details": error_message if not is_valid else None,
|
|
}
|
|
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(status_code=400, detail={"error": "Invalid JSON data"})
|
|
except Exception as e:
|
|
logger.error(f"Error in POST /config/validate: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={"error": "Failed to validate configuration", "details": str(e)},
|
|
)
|
|
|
|
|
|
@router.post("/watch/validate")
|
|
async def validate_watch_config_endpoint(
|
|
request: Request, current_user: User = Depends(require_admin_from_state)
|
|
):
|
|
"""Validate watch configuration without saving it."""
|
|
try:
|
|
watch_data = await request.json()
|
|
if not isinstance(watch_data, dict):
|
|
raise HTTPException(
|
|
status_code=400, detail={"error": "Invalid watch config format"}
|
|
)
|
|
|
|
is_valid, error_message = validate_watch_config(watch_data)
|
|
|
|
return {
|
|
"valid": is_valid,
|
|
"message": "Watch configuration is valid" if is_valid else error_message,
|
|
"details": error_message if not is_valid else None,
|
|
}
|
|
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(status_code=400, detail={"error": "Invalid JSON data"})
|
|
except Exception as e:
|
|
logger.error(f"Error in POST /config/watch/validate: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={
|
|
"error": "Failed to validate watch configuration",
|
|
"details": str(e),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/watch")
|
|
async def handle_watch_config(current_user: User = Depends(require_admin_from_state)):
|
|
"""Handles GET requests for the watch configuration."""
|
|
try:
|
|
watch_config = get_watch_config_http()
|
|
return watch_config
|
|
except Exception as e:
|
|
logger.error(f"Error in GET /config/watch: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={
|
|
"error": "Failed to retrieve watch configuration",
|
|
"details": str(e),
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/watch")
|
|
@router.put("/watch")
|
|
async def update_watch_config(
|
|
request: Request, current_user: User = Depends(require_admin_from_state)
|
|
):
|
|
"""Handles POST/PUT requests to update the watch configuration."""
|
|
try:
|
|
new_watch_config = await request.json()
|
|
if not isinstance(new_watch_config, dict):
|
|
raise HTTPException(
|
|
status_code=400, detail={"error": "Invalid watch config format"}
|
|
)
|
|
|
|
# Validate watch configuration before saving
|
|
is_valid, error_message = validate_watch_config(new_watch_config)
|
|
if not is_valid:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"error": "Watch configuration validation failed",
|
|
"details": error_message,
|
|
},
|
|
)
|
|
|
|
success, error_msg = save_watch_config_http(new_watch_config)
|
|
if success:
|
|
return {"message": "Watch configuration updated successfully"}
|
|
else:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={
|
|
"error": "Failed to update watch configuration",
|
|
"details": error_msg,
|
|
},
|
|
)
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(
|
|
status_code=400, detail={"error": "Invalid JSON data for watch config"}
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error in POST/PUT /config/watch: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail={"error": "Failed to update watch configuration", "details": str(e)},
|
|
)
|