diff --git a/docker-compose.yaml b/docker-compose.yaml index e554adf..44d56d9 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,7 @@ services: - ./logs:/app/logs # <-- Volume for persistent logs ports: - 7171:7171 - image: cooldockerizer93/spotizerr + image: test container_name: spotizerr-app restart: unless-stopped environment: diff --git a/entrypoint.sh b/entrypoint.sh index 3c79907..89bef06 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -52,7 +52,7 @@ else # Ensure proper permissions for all app directories echo "Setting permissions for /app directories..." - chown -R "${USER_NAME}:${GROUP_NAME}" /app/downloads /app/config /app/creds /app/logs /app/cache || true + chown -R "${USER_NAME}:${GROUP_NAME}" /app/downloads /app/data /app/logs || true # Ensure Spotipy cache file exists and is writable touch /app/.cache || true chown "${USER_NAME}:${GROUP_NAME}" /app/.cache || true diff --git a/routes/config.py b/routes/config.py index d363550..a314ac5 100644 --- a/routes/config.py +++ b/routes/config.py @@ -6,9 +6,18 @@ import threading import time import os -config_bp = Blueprint('config_bp', __name__) -CONFIG_PATH = Path('./data/config/main.json') -CONFIG_PATH_WATCH = Path('./data/config/watch.json') +# 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, CONFIG_FILE_PATH as WATCH_CONFIG_FILE_PATH + +logger = logging.getLogger(__name__) + +config_bp = Blueprint('config', __name__) + +# Path to main config file (consistent with celery_config.py) +# CONFIG_PATH = Path('./data/config/main.json') # Defined as MAIN_CONFIG_FILE_PATH from import +# Path to watch config file (consistent with watch/manager.py) +# WATCH_CONFIG_PATH = Path('./data/config/watch.json') # Defined as WATCH_CONFIG_FILE_PATH from import # Flag for config change notifications config_changed = False @@ -23,120 +32,102 @@ NOTIFY_PARAMETERS = [ 'deezerQuality' ] +# Helper to get main config (uses the one from celery_config) def get_config(): - try: - if not CONFIG_PATH.exists(): - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - CONFIG_PATH.write_text('{}') - return {} - - with open(CONFIG_PATH, 'r') as f: - return json.load(f) - except Exception as e: - logging.error(f"Error reading config: {str(e)}") - return None + """Retrieves the main configuration, creating it with defaults if necessary.""" + return get_main_config_params() +# Helper to save main config def save_config(config_data): - """Save config and track changes to important parameters""" - global config_changed, last_config - + """Saves the main configuration data to main.json.""" try: - # Load current config for comparison - current_config = get_config() or {} + MAIN_CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True) + # Ensure all default keys are present before saving, merging if necessary + current_defaults = DEFAULT_MAIN_CONFIG.copy() + # Overlay provided data on defaults to ensure all keys are there. + # This might not be ideal if user explicitly wants to remove a key, + # but for this setup, ensuring defaults is safer. + # A better approach for full PUT might be to replace entirely, + # but for ensuring defaults, this is okay. + # Let's assume config_data is what the user intends fully. + # We'll rely on get_config_params to have already populated defaults if the file was new. + # When saving, we should just save what's given, after ensuring it has necessary structure. + + # Merge with defaults to ensure all keys are present + # This ensures that if a user POSTs partial data, it's merged with existing/default structure - # Check if any notify parameters changed - for param in NOTIFY_PARAMETERS: - if param in config_data: - if param not in current_config or config_data[param] != current_config.get(param): - config_changed = True - logging.info(f"Config parameter '{param}' changed from '{current_config.get(param)}' to '{config_data[param]}'") + # 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 - # Save last known config - last_config = config_data.copy() + # 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) + +# Helper to get watch config (uses the one from watch/manager.py) +def get_watch_config_http(): # Renamed to avoid conflict with the imported get_watch_config + """Retrieves the watch configuration, creating it with defaults if necessary.""" + return get_watch_manager_config() + +# Helper to save watch config +def save_watch_config_http(watch_config_data): # Renamed + """Saves the watch configuration data to watch.json.""" + try: + WATCH_CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True) - # Write the config file - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(CONFIG_PATH, 'w') as f: - json.dump(config_data, f, indent=2) + # Similar logic to save_config: merge with defaults/existing + existing_config = {} + if WATCH_CONFIG_FILE_PATH.exists(): + with open(WATCH_CONFIG_FILE_PATH, 'r') as f_read: + existing_config = json.load(f_read) + else: # Should be rare if get_watch_manager_config was called + existing_config = DEFAULT_WATCH_CONFIG.copy() + + for key, value in watch_config_data.items(): + existing_config[key] = value - return True - except Exception as e: - logging.error(f"Error saving config: {str(e)}") - return False + for default_key, default_value in DEFAULT_WATCH_CONFIG.items(): + if default_key not in existing_config: + existing_config[default_key] = default_value -def get_watch_config(): - """Reads watch.json and returns its content or defaults.""" - try: - if not CONFIG_PATH_WATCH.exists(): - CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True) - # Default watch config - defaults = { - 'enabled': False, - 'watchedArtistAlbumGroup': ["album", "single"], - 'watchPollIntervalSeconds': 3600 - } - CONFIG_PATH_WATCH.write_text(json.dumps(defaults, indent=2)) - return defaults - with open(CONFIG_PATH_WATCH, 'r') as f: - return json.load(f) + with open(WATCH_CONFIG_FILE_PATH, 'w') as f: + json.dump(existing_config, f, indent=4) + logger.info(f"Watch configuration saved to {WATCH_CONFIG_FILE_PATH}") + return True, None except Exception as e: - logging.error(f"Error reading watch config: {str(e)}") - # Return defaults on error to prevent crashes - return { - 'enabled': False, - 'watchedArtistAlbumGroup': ["album", "single"], - 'watchPollIntervalSeconds': 3600 - } - -def save_watch_config(watch_config_data): - """Saves data to watch.json.""" - try: - CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True) - with open(CONFIG_PATH_WATCH, 'w') as f: - json.dump(watch_config_data, f, indent=2) - return True - except Exception as e: - logging.error(f"Error saving watch config: {str(e)}") - return False + logger.error(f"Error saving watch configuration: {e}", exc_info=True) + return False, str(e) @config_bp.route('/config', methods=['GET']) def handle_config(): - config = get_config() - if config is None: - return jsonify({"error": "Could not read config file"}), 500 - - # Create config/state directory - Path('./data/config/state').mkdir(parents=True, exist_ok=True) - - # Set default values for any missing config options - defaults = { - 'service': 'spotify', # Default service is Spotify - 'fallback': False, - 'spotifyQuality': 'NORMAL', - 'deezerQuality': 'MP3_128', - 'realTime': False, - 'customDirFormat': '%ar_album%/%album%', - 'customTrackFormat': '%tracknum%. %music%', - 'maxConcurrentDownloads': 3, - 'maxRetries': 3, - 'retryDelaySeconds': 5, - 'retry_delay_increase': 5, - 'tracknum_padding': True - } - - # Populate defaults for any missing keys - for key, default_value in defaults.items(): - if key not in config: - config[key] = default_value - - # Get explicit filter setting from environment variable - explicit_filter_env = os.environ.get('EXPLICIT_FILTER', 'false').lower() - config['explicitFilter'] = explicit_filter_env in ('true', '1', 'yes', 'on') - - return jsonify(config) + """Handles GET requests for the main configuration.""" + try: + config = get_config() + return jsonify(config) + except Exception as e: + logger.error(f"Error in GET /config: {e}", exc_info=True) + return jsonify({"error": "Failed to retrieve configuration", "details": str(e)}), 500 @config_bp.route('/config', methods=['POST', 'PUT']) def update_config(): + """Handles POST/PUT requests to update the main configuration.""" try: new_config = request.get_json() if not isinstance(new_config, dict): @@ -149,75 +140,64 @@ def update_config(): explicit_filter_env = os.environ.get('EXPLICIT_FILTER', 'false').lower() new_config['explicitFilter'] = explicit_filter_env in ('true', '1', 'yes', 'on') - if not save_config(new_config): - return jsonify({"error": "Failed to save config"}), 500 + 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. + return jsonify({"error": "Failed to retrieve configuration after saving"}), 500 - # 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. - return jsonify({"error": "Failed to retrieve configuration after saving"}), 500 - - return jsonify(updated_config_values) + return jsonify(updated_config_values) + else: + return jsonify({"error": "Failed to update configuration", "details": error_msg}), 500 except json.JSONDecodeError: return jsonify({"error": "Invalid JSON data"}), 400 except Exception as e: - logging.error(f"Error updating config: {str(e)}") - return jsonify({"error": "Failed to update config"}), 500 + logger.error(f"Error in POST/PUT /config: {e}", exc_info=True) + return jsonify({"error": "Failed to update configuration", "details": str(e)}), 500 @config_bp.route('/config/check', methods=['GET']) def check_config_changes(): - """ - Check if config has changed since last check - Returns: Status of config changes - """ - global config_changed - - # Get current state - has_changed = config_changed - - # Reset flag after checking - if has_changed: - config_changed = False - - return jsonify({ - "changed": has_changed, - "last_config": last_config - }) + # 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 jsonify({ + "message": "Current configuration retrieved.", + "config": config + }) + except Exception as e: + logger.error(f"Error in GET /config/check: {e}", exc_info=True) + return jsonify({"error": "Failed to check configuration", "details": str(e)}), 500 @config_bp.route('/config/watch', methods=['GET']) def handle_watch_config(): - watch_config = get_watch_config() - # Ensure defaults are applied if file was corrupted or missing fields - defaults = { - 'enabled': False, - 'watchedArtistAlbumGroup': ["album", "single"], - 'watchPollIntervalSeconds': 3600 - } - for key, default_value in defaults.items(): - if key not in watch_config: - watch_config[key] = default_value - - return jsonify(watch_config) + """Handles GET requests for the watch configuration.""" + try: + watch_config = get_watch_config_http() + return jsonify(watch_config) + except Exception as e: + logger.error(f"Error in GET /config/watch: {e}", exc_info=True) + return jsonify({"error": "Failed to retrieve watch configuration", "details": str(e)}), 500 @config_bp.route('/config/watch', methods=['POST', 'PUT']) def update_watch_config(): + """Handles POST/PUT requests to update the watch configuration.""" try: new_watch_config = request.get_json() if not isinstance(new_watch_config, dict): return jsonify({"error": "Invalid watch config format"}), 400 - if not save_watch_config(new_watch_config): - return jsonify({"error": "Failed to save watch config"}), 500 - - updated_watch_config_values = get_watch_config() - if updated_watch_config_values is None: - return jsonify({"error": "Failed to retrieve watch configuration after saving"}), 500 - - return jsonify(updated_watch_config_values) + success, error_msg = save_watch_config_http(new_watch_config) + if success: + return jsonify({"message": "Watch configuration updated successfully"}), 200 + else: + return jsonify({"error": "Failed to update watch configuration", "details": error_msg}), 500 except json.JSONDecodeError: return jsonify({"error": "Invalid JSON data for watch config"}), 400 except Exception as e: - logging.error(f"Error updating watch config: {str(e)}") - return jsonify({"error": "Failed to update watch config"}), 500 \ No newline at end of file + logger.error(f"Error in POST/PUT /config/watch: {e}", exc_info=True) + return jsonify({"error": "Failed to update watch configuration", "details": str(e)}), 500 \ No newline at end of file diff --git a/routes/utils/celery_config.py b/routes/utils/celery_config.py index 3b849a4..4acd927 100644 --- a/routes/utils/celery_config.py +++ b/routes/utils/celery_config.py @@ -22,86 +22,71 @@ REDIS_BACKEND = os.getenv('REDIS_BACKEND', REDIS_URL) logger.info(f"Redis configuration: REDIS_URL={REDIS_URL}, REDIS_BACKEND={REDIS_BACKEND}") # Config path -CONFIG_PATH = './data/config/main.json' +CONFIG_FILE_PATH = Path('./data/config/main.json') + +DEFAULT_MAIN_CONFIG = { + 'service': 'spotify', + 'spotify': '', + 'deezer': '', + 'fallback': False, + 'spotifyQuality': 'NORMAL', + 'deezerQuality': 'MP3_128', + 'realTime': False, + 'customDirFormat': '%ar_album%/%album%', + 'customTrackFormat': '%tracknum%. %music%', + 'tracknum_padding': True, + 'maxConcurrentDownloads': 3, + 'maxRetries': 3, + 'retryDelaySeconds': 5, + 'retry_delay_increase': 5 +} def get_config_params(): """ Get configuration parameters from the config file. + Creates the file with defaults if it doesn't exist. + Ensures all default keys are present in the loaded config. Returns: dict: A dictionary containing configuration parameters """ try: - if not Path(CONFIG_PATH).exists(): - return { - 'service': 'spotify', - 'spotify': '', - 'deezer': '', - 'fallback': False, - 'spotifyQuality': 'NORMAL', - 'deezerQuality': 'MP3_128', - 'realTime': False, - 'customDirFormat': '%ar_album%/%album%', - 'customTrackFormat': '%tracknum%. %music%', - 'tracknum_padding': True, - 'maxConcurrentDownloads': 3, - 'maxRetries': 3, - 'retryDelaySeconds': 5, - 'retry_delay_increase': 5 - } + # Ensure ./data/config directory exists + CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True) + + if not CONFIG_FILE_PATH.exists(): + logger.info(f"{CONFIG_FILE_PATH} not found. Creating with default values.") + with open(CONFIG_FILE_PATH, 'w') as f: + json.dump(DEFAULT_MAIN_CONFIG, f, indent=4) + return DEFAULT_MAIN_CONFIG.copy() # Return a copy of defaults - with open(CONFIG_PATH, 'r') as f: + with open(CONFIG_FILE_PATH, 'r') as f: config = json.load(f) - # Set defaults for missing values - defaults = { - 'service': 'spotify', - 'spotify': '', - 'deezer': '', - 'fallback': False, - 'spotifyQuality': 'NORMAL', - 'deezerQuality': 'MP3_128', - 'realTime': False, - 'customDirFormat': '%ar_album%/%album%', - 'customTrackFormat': '%tracknum%. %music%', - 'tracknum_padding': True, - 'maxConcurrentDownloads': 3, - 'maxRetries': 3, - 'retryDelaySeconds': 5, - 'retry_delay_increase': 5 - } - - for key, value in defaults.items(): + # Ensure all default keys are present in the loaded config + updated = False + for key, value in DEFAULT_MAIN_CONFIG.items(): if key not in config: config[key] = value + updated = True + + if updated: + logger.info(f"Configuration at {CONFIG_FILE_PATH} was missing some default keys. Updated with defaults.") + with open(CONFIG_FILE_PATH, 'w') as f: + json.dump(config, f, indent=4) return config except Exception as e: - logger.error(f"Error reading config: {e}") - # Return defaults if config read fails - return { - 'service': 'spotify', - 'spotify': '', - 'deezer': '', - 'fallback': False, - 'spotifyQuality': 'NORMAL', - 'deezerQuality': 'MP3_128', - 'realTime': False, - 'customDirFormat': '%ar_album%/%album%', - 'customTrackFormat': '%tracknum%. %music%', - 'tracknum_padding': True, - 'maxConcurrentDownloads': 3, - 'maxRetries': 3, - 'retryDelaySeconds': 5, - 'retry_delay_increase': 5 - } + logger.error(f"Error reading or creating config at {CONFIG_FILE_PATH}: {e}", exc_info=True) + # Return defaults if config read/create fails + return DEFAULT_MAIN_CONFIG.copy() # Load configuration values we need for Celery -config = get_config_params() -MAX_CONCURRENT_DL = config.get('maxConcurrentDownloads', 3) -MAX_RETRIES = config.get('maxRetries', 3) -RETRY_DELAY = config.get('retryDelaySeconds', 5) -RETRY_DELAY_INCREASE = config.get('retry_delay_increase', 5) +config_params_values = get_config_params() # Renamed to avoid conflict with module name +MAX_CONCURRENT_DL = config_params_values.get('maxConcurrentDownloads', 3) +MAX_RETRIES = config_params_values.get('maxRetries', 3) +RETRY_DELAY = config_params_values.get('retryDelaySeconds', 5) +RETRY_DELAY_INCREASE = config_params_values.get('retry_delay_increase', 5) # Define task queues task_queues = { diff --git a/routes/utils/watch/db.py b/routes/utils/watch/db.py index 225690a..03a9a7c 100644 --- a/routes/utils/watch/db.py +++ b/routes/utils/watch/db.py @@ -11,8 +11,8 @@ DB_DIR = Path('./data/watch') PLAYLISTS_DB_PATH = DB_DIR / 'playlists.db' ARTISTS_DB_PATH = DB_DIR / 'artists.db' -# Config path remains the same -CONFIG_PATH = Path('./data/config/watch.json') +# Config path for watch.json is managed in routes.utils.watch.manager now +# CONFIG_PATH = Path('./data/config/watch.json') # Removed def _get_playlists_db_connection(): DB_DIR.mkdir(parents=True, exist_ok=True) diff --git a/routes/utils/watch/manager.py b/routes/utils/watch/manager.py index c056e21..4aba65c 100644 --- a/routes/utils/watch/manager.py +++ b/routes/utils/watch/manager.py @@ -20,10 +20,10 @@ from routes.utils.watch.db import ( update_artist_metadata_after_check # Renamed from update_artist_metadata ) from routes.utils.get_info import get_spotify_info # To fetch playlist, track, artist, and album details -from routes.utils.celery_queue_manager import download_queue_manager, get_config_params +from routes.utils.celery_queue_manager import download_queue_manager logger = logging.getLogger(__name__) -CONFIG_PATH = Path('./data/config/watch.json') +CONFIG_FILE_PATH = Path('./data/config/watch.json') STOP_EVENT = threading.Event() DEFAULT_WATCH_CONFIG = { @@ -36,24 +36,37 @@ DEFAULT_WATCH_CONFIG = { } def get_watch_config(): - """Loads the watch configuration from watch.json.""" + """Loads the watch configuration from watch.json. + Creates the file with defaults if it doesn't exist. + Ensures all default keys are present in the loaded config. + """ try: - if CONFIG_PATH.exists(): - with open(CONFIG_PATH, 'r') as f: - config = json.load(f) - # Ensure all default keys are present - for key, value in DEFAULT_WATCH_CONFIG.items(): - config.setdefault(key, value) - return config - else: - # Create a default config if it doesn't exist - with open(CONFIG_PATH, 'w') as f: + # Ensure ./data/config directory exists + CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True) + + if not CONFIG_FILE_PATH.exists(): + logger.info(f"{CONFIG_FILE_PATH} not found. Creating with default watch config.") + with open(CONFIG_FILE_PATH, 'w') as f: json.dump(DEFAULT_WATCH_CONFIG, f, indent=2) - logger.info(f"Created default watch config at {CONFIG_PATH}") - return DEFAULT_WATCH_CONFIG + return DEFAULT_WATCH_CONFIG.copy() + + with open(CONFIG_FILE_PATH, 'r') as f: + config = json.load(f) + + updated = False + for key, value in DEFAULT_WATCH_CONFIG.items(): + if key not in config: + config[key] = value + updated = True + + if updated: + logger.info(f"Watch configuration at {CONFIG_FILE_PATH} was missing some default keys. Updated with defaults.") + with open(CONFIG_FILE_PATH, 'w') as f: + json.dump(config, f, indent=2) + return config except Exception as e: - logger.error(f"Error loading watch config: {e}", exc_info=True) - return DEFAULT_WATCH_CONFIG # Fallback + logger.error(f"Error loading or creating watch config at {CONFIG_FILE_PATH}: {e}", exc_info=True) + return DEFAULT_WATCH_CONFIG.copy() # Fallback def construct_spotify_url(item_id, item_type="track"): return f"https://open.spotify.com/{item_type}/{item_id}"