diff --git a/.gitignore b/.gitignore index 351e496..5ac9910 100755 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ celery_worker.log logs/spotizerr.log /.venv static/js +data diff --git a/routes/album.py b/routes/album.py index 7ec11e5..0a973ca 100755 --- a/routes/album.py +++ b/routes/album.py @@ -2,7 +2,10 @@ from flask import Blueprint, Response, request import json import os import traceback +import uuid +import time from routes.utils.celery_queue_manager import download_queue_manager +from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState album_bp = Blueprint('album', __name__) @@ -26,13 +29,38 @@ def handle_download(): # Include full original request URL in metadata orig_params = request.args.to_dict() orig_params["original_url"] = request.url - task_id = download_queue_manager.add_task({ - "download_type": "album", - "url": url, - "name": name, - "artist": artist, - "orig_request": orig_params - }) + try: + task_id = download_queue_manager.add_task({ + "download_type": "album", + "url": url, + "name": name, + "artist": artist, + "orig_request": orig_params + }) + except Exception as e: + # Generic error handling for other issues during task submission + # Create an error task ID if add_task itself fails before returning an ID + error_task_id = str(uuid.uuid4()) + + store_task_info(error_task_id, { + "download_type": "album", + "url": url, + "name": name, + "artist": artist, + "original_request": orig_params, + "created_at": time.time(), + "is_submission_error_task": True + }) + store_task_status(error_task_id, { + "status": ProgressState.ERROR, + "error": f"Failed to queue album download: {str(e)}", + "timestamp": time.time() + }) + return Response( + json.dumps({"error": f"Failed to queue album download: {str(e)}", "task_id": error_task_id}), + status=500, + mimetype='application/json' + ) return Response( json.dumps({"prg_file": task_id}), diff --git a/routes/artist.py b/routes/artist.py index c679688..b3b747f 100644 --- a/routes/artist.py +++ b/routes/artist.py @@ -40,20 +40,25 @@ def handle_artist_download(): from routes.utils.artist import download_artist_albums # Delegate to the download_artist_albums function which will handle album filtering - task_ids = download_artist_albums( + successfully_queued_albums, duplicate_albums = download_artist_albums( url=url, album_type=album_type, request_args=request.args.to_dict() ) # Return the list of album task IDs. + response_data = { + "status": "complete", + "message": f"Artist discography processing initiated. {len(successfully_queued_albums)} albums queued.", + "queued_albums": successfully_queued_albums + } + if duplicate_albums: + response_data["duplicate_albums"] = duplicate_albums + response_data["message"] += f" {len(duplicate_albums)} albums were already in progress or queued." + return Response( - json.dumps({ - "status": "complete", - "task_ids": task_ids, - "message": f"Artist discography queued – {len(task_ids)} album tasks have been queued." - }), - status=202, + json.dumps(response_data), + status=202, # Still 202 Accepted as some operations may have succeeded mimetype='application/json' ) except Exception as e: diff --git a/routes/config.py b/routes/config.py index de38a76..66e27cb 100644 --- a/routes/config.py +++ b/routes/config.py @@ -7,7 +7,7 @@ import time import os config_bp = Blueprint('config_bp', __name__) -CONFIG_PATH = Path('./config/main.json') +CONFIG_PATH = Path('./data/config/main.json') # Flag for config change notifications config_changed = False @@ -70,7 +70,7 @@ def handle_config(): return jsonify({"error": "Could not read config file"}), 500 # Create config/state directory - Path('./config/state').mkdir(parents=True, exist_ok=True) + Path('./data/config/state').mkdir(parents=True, exist_ok=True) # Set default values for any missing config options defaults = { @@ -116,7 +116,14 @@ def update_config(): if not save_config(new_config): return jsonify({"error": "Failed to save config"}), 500 - return jsonify({"message": "Config updated successfully"}) + # 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) except json.JSONDecodeError: return jsonify({"error": "Invalid JSON data"}), 400 except Exception as e: diff --git a/routes/credentials.py b/routes/credentials.py index 0461de6..571d22f 100755 --- a/routes/credentials.py +++ b/routes/credentials.py @@ -69,7 +69,7 @@ def handle_search_credential(service, name): return jsonify({"error": "Both client_id and client_secret are required"}), 400 # For POST, first check if the credentials directory exists - if request.method == 'POST' and not any(Path(f'./creds/{service}/{name}').glob('*.json')): + if request.method == 'POST' and not any(Path(f'./data/{service}/{name}').glob('*.json')): return jsonify({"error": f"Account '{name}' doesn't exist. Create it first."}), 404 # Create or update search credentials diff --git a/routes/playlist.py b/routes/playlist.py index feb7eb8..144e461 100755 --- a/routes/playlist.py +++ b/routes/playlist.py @@ -2,7 +2,10 @@ from flask import Blueprint, Response, request import os import json import traceback +import uuid # For generating error task IDs +import time # For timestamps from routes.utils.celery_queue_manager import download_queue_manager +from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState # For error task creation playlist_bp = Blueprint('playlist', __name__) @@ -12,6 +15,8 @@ def handle_download(): url = request.args.get('url') name = request.args.get('name') artist = request.args.get('artist') + orig_params = request.args.to_dict() + orig_params["original_url"] = request.url # Validate required parameters if not url: @@ -21,21 +26,40 @@ def handle_download(): mimetype='application/json' ) - # Add the task to the queue with only essential parameters - # The queue manager will now handle all config parameters - # Include full original request URL in metadata - orig_params = request.args.to_dict() - orig_params["original_url"] = request.url - task_id = download_queue_manager.add_task({ - "download_type": "playlist", - "url": url, - "name": name, - "artist": artist, - "orig_request": orig_params - }) + try: + task_id = download_queue_manager.add_task({ + "download_type": "playlist", + "url": url, + "name": name, + "artist": artist, + "orig_request": orig_params + }) + # Removed DuplicateDownloadError handling, add_task now manages this by creating an error task. + except Exception as e: + # Generic error handling for other issues during task submission + error_task_id = str(uuid.uuid4()) + store_task_info(error_task_id, { + "download_type": "playlist", + "url": url, + "name": name, + "artist": artist, + "original_request": orig_params, + "created_at": time.time(), + "is_submission_error_task": True + }) + store_task_status(error_task_id, { + "status": ProgressState.ERROR, + "error": f"Failed to queue playlist download: {str(e)}", + "timestamp": time.time() + }) + return Response( + json.dumps({"error": f"Failed to queue playlist download: {str(e)}", "task_id": error_task_id}), + status=500, + mimetype='application/json' + ) return Response( - json.dumps({"prg_file": task_id}), + json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id status=202, mimetype='application/json' ) diff --git a/routes/track.py b/routes/track.py index 609a441..7fdbfed 100755 --- a/routes/track.py +++ b/routes/track.py @@ -2,7 +2,10 @@ from flask import Blueprint, Response, request import os import json import traceback +import uuid # For generating error task IDs +import time # For timestamps from routes.utils.celery_queue_manager import download_queue_manager +from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState # For error task creation from urllib.parse import urlparse # for URL validation track_bp = Blueprint('track', __name__) @@ -13,6 +16,8 @@ def handle_download(): url = request.args.get('url') name = request.args.get('name') artist = request.args.get('artist') + orig_params = request.args.to_dict() + orig_params["original_url"] = request.url # Validate required parameters if not url: @@ -31,20 +36,40 @@ def handle_download(): mimetype='application/json' ) - # Add the task to the queue with only essential parameters - # The queue manager will now handle all config parameters - orig_params = request.args.to_dict() - orig_params["original_url"] = request.url - task_id = download_queue_manager.add_task({ - "download_type": "track", - "url": url, - "name": name, - "artist": artist, - "orig_request": orig_params - }) + try: + task_id = download_queue_manager.add_task({ + "download_type": "track", + "url": url, + "name": name, + "artist": artist, + "orig_request": orig_params + }) + # Removed DuplicateDownloadError handling, add_task now manages this by creating an error task. + except Exception as e: + # Generic error handling for other issues during task submission + error_task_id = str(uuid.uuid4()) + store_task_info(error_task_id, { + "download_type": "track", + "url": url, + "name": name, + "artist": artist, + "original_request": orig_params, + "created_at": time.time(), + "is_submission_error_task": True + }) + store_task_status(error_task_id, { + "status": ProgressState.ERROR, + "error": f"Failed to queue track download: {str(e)}", + "timestamp": time.time() + }) + return Response( + json.dumps({"error": f"Failed to queue track download: {str(e)}", "task_id": error_task_id}), + status=500, + mimetype='application/json' + ) return Response( - json.dumps({"prg_file": task_id}), + json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id status=202, mimetype='application/json' ) diff --git a/routes/utils/album.py b/routes/utils/album.py index 2a13a43..0cf35fb 100755 --- a/routes/utils/album.py +++ b/routes/utils/album.py @@ -47,11 +47,11 @@ def download_album( # Smartly determine where to look for Spotify search credentials if service == 'spotify' and fallback: # If fallback is enabled, use the fallback account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{fallback}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json') print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}") else: # Otherwise use the main account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}") if search_creds_path.exists(): @@ -77,7 +77,7 @@ def download_album( deezer_error = None try: # Load Deezer credentials from 'main' under deezer directory - deezer_creds_dir = os.path.join('./creds/deezer', main) + deezer_creds_dir = os.path.join('./data/creds/deezer', main) deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json')) # DEBUG: Print Deezer credential paths being used @@ -89,8 +89,8 @@ def download_album( # List available directories to compare print(f"DEBUG: Available Deezer credential directories:") - for dir_name in os.listdir('./creds/deezer'): - print(f"DEBUG: ./creds/deezer/{dir_name}") + for dir_name in os.listdir('./data/creds/deezer'): + print(f"DEBUG: ./data/creds/deezer/{dir_name}") with open(deezer_creds_path, 'r') as f: deezer_creds = json.load(f) @@ -129,7 +129,7 @@ def download_album( # Load fallback Spotify credentials and attempt download try: - spo_creds_dir = os.path.join('./creds/spotify', fallback) + spo_creds_dir = os.path.join('./data/creds/spotify', fallback) spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json')) print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}") @@ -173,7 +173,7 @@ def download_album( # Original behavior: use Spotify main if quality is None: quality = 'HIGH' - creds_dir = os.path.join('./creds/spotify', main) + creds_dir = os.path.join('./data/creds/spotify', main) credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) print(f"DEBUG: Using Spotify main credentials from: {credentials_path}") print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}") @@ -208,7 +208,7 @@ def download_album( if quality is None: quality = 'FLAC' # Existing code remains the same, ignoring fallback - creds_dir = os.path.join('./creds/deezer', main) + creds_dir = os.path.join('./data/creds/deezer', main) creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) print(f"DEBUG: Using Deezer credentials from: {creds_path}") print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}") diff --git a/routes/utils/artist.py b/routes/utils/artist.py index 6510b0d..e0cac69 100644 --- a/routes/utils/artist.py +++ b/routes/utils/artist.py @@ -6,6 +6,7 @@ import logging from flask import Blueprint, Response, request, url_for from routes.utils.celery_queue_manager import download_queue_manager, get_config_params from routes.utils.get_info import get_spotify_info +from routes.utils.celery_tasks import get_last_task_status, ProgressState from deezspot.easy_spoty import Spo from deezspot.libutils.utils import get_ids, link_is_valid @@ -32,7 +33,7 @@ def get_artist_discography(url, main, album_type='album,single,compilation,appea # Initialize Spotify API with credentials spotify_client_id = None spotify_client_secret = None - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') if search_creds_path.exists(): try: with open(search_creds_path, 'r') as f: @@ -76,7 +77,7 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a request_args (dict): Original request arguments for tracking Returns: - list: List of task IDs for the queued album downloads + tuple: (list of successfully queued albums, list of duplicate albums) """ if not url: raise ValueError("Missing required parameter: url") @@ -133,10 +134,12 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a if not filtered_albums: logger.warning(f"No albums match the specified types: {album_type}") - return [] + return [], [] # Queue each album as a separate download task album_task_ids = [] + successfully_queued_albums = [] + duplicate_albums = [] # To store info about albums that were duplicates for album in filtered_albums: # Add detailed logging to inspect each album's structure and URLs @@ -185,10 +188,38 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a # Debug log the task data being sent to the queue logger.debug(f"Album task data: url={task_data['url']}, retry_url={task_data['retry_url']}") - # Add the task to the queue manager - task_id = download_queue_manager.add_task(task_data) - album_task_ids.append(task_id) - logger.info(f"Queued album download: {album_name} ({task_id})") + try: + task_id = download_queue_manager.add_task(task_data) + + # Check the status of the newly added task to see if it was marked as a duplicate error + last_status = get_last_task_status(task_id) + + if last_status and last_status.get("status") == ProgressState.ERROR and last_status.get("existing_task_id"): + logger.warning(f"Album {album_name} (URL: {album_url}) is a duplicate. Error task ID: {task_id}. Existing task ID: {last_status.get('existing_task_id')}") + duplicate_albums.append({ + "name": album_name, + "artist": album_artist, + "url": album_url, + "error_task_id": task_id, # This is the ID of the task marked as a duplicate error + "existing_task_id": last_status.get("existing_task_id"), + "message": last_status.get("message", "Duplicate download attempt.") + }) + else: + # If not a duplicate error, it was successfully queued (or failed for other reasons handled by add_task) + # We only add to successfully_queued_albums if it wasn't a duplicate error from add_task + # Other errors from add_task (like submission failure) would also result in an error status for task_id + # but won't have 'existing_task_id'. The client can check the status of this task_id. + album_task_ids.append(task_id) # Keep track of all task_ids returned by add_task + successfully_queued_albums.append({ + "name": album_name, + "artist": album_artist, + "url": album_url, + "task_id": task_id + }) + logger.info(f"Queued album download: {album_name} ({task_id})") + except Exception as e: # Catch any other unexpected error from add_task itself (though it should be rare now) + logger.error(f"Failed to queue album {album_name} due to an unexpected error in add_task: {str(e)}") + # Optionally, collect these errors. For now, just logging and continuing. - logger.info(f"Queued {len(album_task_ids)} album downloads for artist: {artist_name}") - return album_task_ids + logger.info(f"Artist album processing: {len(successfully_queued_albums)} queued, {len(duplicate_albums)} duplicates found.") + return successfully_queued_albums, duplicate_albums diff --git a/routes/utils/celery_config.py b/routes/utils/celery_config.py index f455ae6..3b849a4 100644 --- a/routes/utils/celery_config.py +++ b/routes/utils/celery_config.py @@ -22,7 +22,7 @@ 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 = './config/main.json' +CONFIG_PATH = './data/config/main.json' def get_config_params(): """ diff --git a/routes/utils/celery_manager.py b/routes/utils/celery_manager.py index ea140ad..59d5bba 100644 --- a/routes/utils/celery_manager.py +++ b/routes/utils/celery_manager.py @@ -17,7 +17,8 @@ from .celery_tasks import ( get_task_info, get_last_task_status, store_task_status, - get_all_tasks as get_all_celery_tasks_info + get_all_tasks as get_all_celery_tasks_info, + cleanup_stale_errors ) from .celery_config import get_config_params @@ -25,7 +26,7 @@ from .celery_config import get_config_params logger = logging.getLogger(__name__) # Configuration -CONFIG_PATH = './config/main.json' +CONFIG_PATH = './data/config/main.json' CELERY_APP = 'routes.utils.celery_tasks.celery_app' CELERY_PROCESS = None CONFIG_CHECK_INTERVAL = 30 # seconds @@ -39,6 +40,7 @@ class CeleryManager: self.celery_process = None self.current_worker_count = 0 self.monitoring_thread = None + self.error_cleanup_thread = None self.running = False self.log_queue = queue.Queue() self.output_threads = [] @@ -114,6 +116,10 @@ class CeleryManager: # Start monitoring thread for config changes self.monitoring_thread = threading.Thread(target=self._monitor_config, daemon=True) self.monitoring_thread.start() + + # Start periodic error cleanup thread + self.error_cleanup_thread = threading.Thread(target=self._run_periodic_error_cleanup, daemon=True) + self.error_cleanup_thread.start() # Register shutdown handler atexit.register(self.stop) @@ -325,5 +331,24 @@ class CeleryManager: logger.error(f"Error in config monitoring thread: {e}") time.sleep(5) # Wait before retrying + def _run_periodic_error_cleanup(self): + """Periodically triggers the cleanup_stale_errors Celery task.""" + cleanup_interval = 60 # Run cleanup task every 60 seconds + logger.info(f"Starting periodic error cleanup scheduler (runs every {cleanup_interval}s).") + while self.running: + try: + logger.info("Scheduling cleanup_stale_errors task...") + cleanup_stale_errors.delay() # Call the Celery task + except Exception as e: + logger.error(f"Error scheduling cleanup_stale_errors task: {e}", exc_info=True) + + # Wait for the next interval + # Use a loop to check self.running more frequently to allow faster shutdown + for _ in range(cleanup_interval): + if not self.running: + break + time.sleep(1) + logger.info("Periodic error cleanup scheduler stopped.") + # Create single instance celery_manager = CeleryManager() \ No newline at end of file diff --git a/routes/utils/celery_queue_manager.py b/routes/utils/celery_queue_manager.py index 010665a..eb3aafd 100644 --- a/routes/utils/celery_queue_manager.py +++ b/routes/utils/celery_queue_manager.py @@ -25,7 +25,7 @@ from routes.utils.celery_tasks import ( logger = logging.getLogger(__name__) # Load configuration -CONFIG_PATH = './config/main.json' +CONFIG_PATH = './data/config/main.json' try: with open(CONFIG_PATH, 'r') as f: config_data = json.load(f) @@ -96,16 +96,89 @@ class CeleryDownloadQueueManager: def add_task(self, task): """ - Add a new download task to the Celery queue + Add a new download task to the Celery queue. + If a duplicate active task is found, a new task ID is created and immediately set to an ERROR state. Args: task (dict): Task parameters including download_type, url, etc. Returns: - str: Task ID + str: Task ID (either for a new task or for a new error-state task if duplicate detected). """ try: - # Extract essential parameters + # Extract essential parameters for duplicate check + incoming_url = task.get("url") + incoming_type = task.get("download_type", "unknown") + + if not incoming_url: + # This should ideally be validated before calling add_task + # For now, let it proceed and potentially fail in Celery task if URL is vital and missing. + # Or, create an error task immediately if URL is strictly required for any task logging. + logger.warning("Task being added with no URL. Duplicate check might be unreliable.") + + # --- Check for Duplicates --- + NON_BLOCKING_STATES = [ + ProgressState.COMPLETE, + ProgressState.CANCELLED, + ProgressState.ERROR + ] + + all_existing_tasks_summary = get_all_tasks() + if incoming_url: # Only check for duplicates if we have a URL + for task_summary in all_existing_tasks_summary: + existing_task_id = task_summary.get("task_id") + if not existing_task_id: + continue + + existing_task_info = get_task_info(existing_task_id) + existing_last_status_obj = get_last_task_status(existing_task_id) + + if not existing_task_info or not existing_last_status_obj: + continue + + existing_url = existing_task_info.get("url") + existing_type = existing_task_info.get("download_type") + existing_status = existing_last_status_obj.get("status") + + if (existing_url == incoming_url and + existing_type == incoming_type and + existing_status not in NON_BLOCKING_STATES): + + message = f"Duplicate download: URL '{incoming_url}' (type: {incoming_type}) is already being processed by task {existing_task_id} (status: {existing_status})." + logger.warning(message) + + # Create a new task_id for this duplicate request and mark it as an error + error_task_id = str(uuid.uuid4()) + + # Store minimal info for this error task + error_task_info_payload = { + "download_type": incoming_type, + "type": task.get("type", incoming_type), + "name": task.get("name", "Duplicate Task"), + "artist": task.get("artist", ""), + "url": incoming_url, + "original_request": task.get("orig_request", task.get("original_request", {})), + "created_at": time.time(), + "is_duplicate_error_task": True + } + store_task_info(error_task_id, error_task_info_payload) + + # Store error status for this new task_id + error_status_payload = { + "status": ProgressState.ERROR, + "error": message, + "existing_task_id": existing_task_id, # So client knows which task it duplicates + "timestamp": time.time(), + "type": error_task_info_payload["type"], + "name": error_task_info_payload["name"], + "artist": error_task_info_payload["artist"] + } + store_task_status(error_task_id, error_status_payload) + + return error_task_id # Return the ID of this new error-state task + # --- End Duplicate Check --- + + # Proceed with normal task creation if no duplicate found or no URL to check download_type = task.get("download_type", "unknown") # Debug existing task data diff --git a/routes/utils/celery_tasks.py b/routes/utils/celery_tasks.py index c671328..5565752 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -44,6 +44,8 @@ class ProgressState: REAL_TIME = "real_time" SKIPPED = "skipped" DONE = "done" + ERROR_RETRIED = "ERROR_RETRIED" # Status for an error task that has been retried + ERROR_AUTO_CLEANED = "ERROR_AUTO_CLEANED" # Status for an error task that was auto-cleaned # Reuse the application's logging configuration for Celery workers @setup_logging.connect @@ -146,7 +148,7 @@ def cancel_task(task_id): # Mark the task as cancelled in Redis store_task_status(task_id, { "status": ProgressState.CANCELLED, - "message": "Task cancelled by user", + "error": "Task cancelled by user", "timestamp": time.time() }) @@ -165,12 +167,12 @@ def retry_task(task_id): # Get task info task_info = get_task_info(task_id) if not task_info: - return {"status": "error", "message": f"Task {task_id} not found"} + return {"status": "error", "error": f"Task {task_id} not found"} # Check if task has error status last_status = get_last_task_status(task_id) if not last_status or last_status.get("status") != ProgressState.ERROR: - return {"status": "error", "message": "Task is not in a failed state"} + return {"status": "error", "error": "Task is not in a failed state"} # Get current retry count retry_count = last_status.get("retry_count", 0) @@ -185,7 +187,7 @@ def retry_task(task_id): if retry_count >= max_retries: return { "status": "error", - "message": f"Maximum retry attempts ({max_retries}) exceeded" + "error": f"Maximum retry attempts ({max_retries}) exceeded" } # Calculate retry delay @@ -255,34 +257,51 @@ def retry_task(task_id): # Launch the appropriate task based on download_type download_type = task_info.get("download_type", "unknown") - task = None + new_celery_task_obj = None logger.info(f"Retrying task {task_id} as {new_task_id} (retry {retry_count + 1}/{max_retries})") if download_type == "track": - task = download_track.apply_async( + new_celery_task_obj = download_track.apply_async( kwargs=task_info, task_id=new_task_id, queue='downloads' ) elif download_type == "album": - task = download_album.apply_async( + new_celery_task_obj = download_album.apply_async( kwargs=task_info, task_id=new_task_id, queue='downloads' ) elif download_type == "playlist": - task = download_playlist.apply_async( + new_celery_task_obj = download_playlist.apply_async( kwargs=task_info, task_id=new_task_id, queue='downloads' ) else: logger.error(f"Unknown download type for retry: {download_type}") + store_task_status(new_task_id, { + "status": ProgressState.ERROR, + "error": f"Cannot retry: Unknown download type '{download_type}' for original task {task_id}", + "timestamp": time.time() + }) return { "status": "error", - "message": f"Unknown download type: {download_type}" + "error": f"Unknown download type: {download_type}" } + + # If retry was successfully submitted, update the original task's status + if new_celery_task_obj: + store_task_status(task_id, { + "status": "ERROR_RETRIED", + "error": f"Task superseded by retry: {new_task_id}", + "retried_as_task_id": new_task_id, + "timestamp": time.time() + }) + logger.info(f"Original task {task_id} status updated to ERROR_RETRIED, superseded by {new_task_id}") + else: + logger.error(f"Retry submission for task {task_id} (as {new_task_id}) did not return a Celery AsyncResult. Original task not marked as ERROR_RETRIED.") return { "status": "requeued", @@ -292,9 +311,8 @@ def retry_task(task_id): "retry_delay": retry_delay } except Exception as e: - logger.error(f"Error retrying task {task_id}: {e}") - traceback.print_exc() - return {"status": "error", "message": str(e)} + logger.error(f"Error retrying task {task_id}: {e}", exc_info=True) + return {"status": "error", "error": str(e)} def get_all_tasks(): """Get all active task IDs""" @@ -657,8 +675,9 @@ class ProgressTrackingTask(Task): task_info['error_count'] = error_count store_task_info(task_id, task_info) - # Set status + # Set status and error message data['status'] = ProgressState.ERROR + data['error'] = message def _handle_done(self, task_id, data, task_info): """Handle done status from deezspot""" @@ -812,22 +831,21 @@ def task_failure_handler(task_id=None, exception=None, traceback=None, *args, ** can_retry = retry_count < max_retries # Update task status to error - error_message = str(exception) + error_message_str = str(exception) store_task_status(task_id, { "status": ProgressState.ERROR, "timestamp": time.time(), "type": task_info.get("type", "unknown"), "name": task_info.get("name", "Unknown"), "artist": task_info.get("artist", ""), - "error": error_message, + "error": error_message_str, "traceback": str(traceback), "can_retry": can_retry, "retry_count": retry_count, - "max_retries": max_retries, - "message": f"Error: {error_message}" + "max_retries": max_retries }) - logger.error(f"Task {task_id} failed: {error_message}") + logger.error(f"Task {task_id} failed: {error_message_str}") if can_retry: logger.info(f"Task {task_id} can be retried ({retry_count}/{max_retries})") except Exception as e: @@ -1053,4 +1071,71 @@ def download_playlist(self, **task_data): except Exception as e: logger.error(f"Error in download_playlist task: {e}") traceback.print_exc() - raise \ No newline at end of file + raise + +# Helper function to fully delete task data from Redis +def delete_task_data_and_log(task_id, reason="Task data deleted"): + """ + Marks a task as cancelled (if not already) and deletes all its data from Redis. + """ + try: + task_info = get_task_info(task_id) # Get info before deleting + last_status = get_last_task_status(task_id) + + # Update status to cancelled if it's not already in a terminal state that implies deletion is okay + if not last_status or last_status.get("status") not in [ProgressState.CANCELLED, ProgressState.ERROR_RETRIED, ProgressState.ERROR_AUTO_CLEANED]: + store_task_status(task_id, { + "status": ProgressState.ERROR_AUTO_CLEANED, # Use specific status + "error": reason, + "timestamp": time.time() + }) + + # Delete Redis keys associated with the task + redis_client.delete(f"task:{task_id}:info") + redis_client.delete(f"task:{task_id}:status") + redis_client.delete(f"task:{task_id}:status:next_id") + + logger.info(f"Data for task {task_id} ('{task_info.get('name', 'Unknown')}') deleted from Redis. Reason: {reason}") + return True + except Exception as e: + logger.error(f"Error deleting task data for {task_id}: {e}", exc_info=True) + return False + +@celery_app.task(name="cleanup_stale_errors", queue="default") # Put on default queue, not downloads +def cleanup_stale_errors(): + """ + Periodically checks for tasks in ERROR state for more than 1 minute and cleans them up. + """ + logger.info("Running cleanup_stale_errors task...") + cleaned_count = 0 + try: + task_keys = redis_client.keys("task:*:info") + if not task_keys: + logger.info("No task keys found for cleanup.") + return {"status": "complete", "message": "No tasks to check."} + + current_time = time.time() + stale_threshold = 60 # 1 minute + + for key_bytes in task_keys: + task_id = key_bytes.decode('utf-8').split(':')[1] + last_status = get_last_task_status(task_id) + + if last_status and last_status.get("status") == ProgressState.ERROR: + error_timestamp = last_status.get("timestamp", 0) + if (current_time - error_timestamp) > stale_threshold: + # Check again to ensure it wasn't retried just before cleanup + current_last_status_before_delete = get_last_task_status(task_id) + if current_last_status_before_delete and current_last_status_before_delete.get("status") == ProgressState.ERROR_RETRIED: + logger.info(f"Task {task_id} was retried just before cleanup. Skipping delete.") + continue + + logger.info(f"Task {task_id} is in ERROR state for more than {stale_threshold}s. Cleaning up.") + if delete_task_data_and_log(task_id, reason=f"Auto-cleaned: Task was in ERROR state for over {stale_threshold} seconds without manual retry."): + cleaned_count += 1 + + logger.info(f"cleanup_stale_errors task finished. Cleaned up {cleaned_count} stale errored tasks.") + return {"status": "complete", "cleaned_count": cleaned_count} + except Exception as e: + logger.error(f"Error during cleanup_stale_errors: {e}", exc_info=True) + return {"status": "error", "error": str(e)} \ No newline at end of file diff --git a/routes/utils/credentials.py b/routes/utils/credentials.py index 1d7c97b..0ed671b 100755 --- a/routes/utils/credentials.py +++ b/routes/utils/credentials.py @@ -1,6 +1,85 @@ import json from pathlib import Path import shutil +from deezspot.spotloader import SpoLogin +from deezspot.deezloader import DeeLogin +import traceback # For logging detailed error messages +import time # For retry delays + +def _get_spotify_search_creds(creds_dir: Path): + """Helper to load client_id and client_secret from search.json for a Spotify account.""" + search_file = creds_dir / 'search.json' + if search_file.exists(): + try: + with open(search_file, 'r') as f: + search_data = json.load(f) + return search_data.get('client_id'), search_data.get('client_secret') + except Exception: + # Log error if search.json is malformed or unreadable + print(f"Warning: Could not read Spotify search credentials from {search_file}") + traceback.print_exc() + return None, None + +def _validate_with_retry(service_name, account_name, creds_dir_path, cred_file_path, data_for_validation, is_spotify): + """ + Attempts to validate credentials with retries for connection errors. + - For Spotify, cred_file_path is used. + - For Deezer, data_for_validation (which contains the 'arl' key) is used. + Returns True if validated, raises ValueError if not. + """ + max_retries = 5 + last_exception = None + + for attempt in range(max_retries): + try: + if is_spotify: + client_id, client_secret = _get_spotify_search_creds(creds_dir_path) + SpoLogin(credentials_path=str(cred_file_path), spotify_client_id=client_id, spotify_client_secret=client_secret) + else: # Deezer + arl = data_for_validation.get('arl') + if not arl: + # This should be caught by prior checks, but as a safeguard: + raise ValueError("Missing 'arl' for Deezer validation.") + DeeLogin(arl=arl) + + print(f"{service_name.capitalize()} credentials for {account_name} validated successfully (attempt {attempt + 1}).") + return True # Validation successful + except Exception as e: + last_exception = e + error_str = str(e).lower() + # More comprehensive check for connection-related errors + is_connection_error = ( + "connection refused" in error_str or + "connection error" in error_str or + "timeout" in error_str or + "temporary failure in name resolution" in error_str or + "dns lookup failed" in error_str or + "network is unreachable" in error_str or + "ssl handshake failed" in error_str or # Can be network-related + "connection reset by peer" in error_str + ) + + if is_connection_error and attempt < max_retries - 1: + retry_delay = 2 + attempt # Increasing delay (2s, 3s, 4s, 5s) + print(f"Validation for {account_name} ({service_name}) failed on attempt {attempt + 1}/{max_retries} due to connection issue: {e}. Retrying in {retry_delay}s...") + time.sleep(retry_delay) + continue # Go to next retry attempt + else: + # Not a connection error, or it's the last retry for a connection error + print(f"Validation for {account_name} ({service_name}) failed on attempt {attempt + 1} with non-retryable error or max retries reached for connection error.") + break # Exit retry loop + + # If loop finished without returning True, validation failed + print(f"ERROR: Credential validation definitively failed for {service_name} account {account_name} after {attempt + 1} attempt(s).") + if last_exception: + base_error_message = str(last_exception).splitlines()[-1] + detailed_error_message = f"Invalid {service_name} credentials. Verification failed: {base_error_message}" + if is_spotify and "incorrect padding" in base_error_message.lower(): + detailed_error_message += ". Hint: Do not throw your password here, read the docs" + # traceback.print_exc() # Already printed in create/edit, avoid duplicate full trace + raise ValueError(detailed_error_message) + else: # Should not happen if loop runs at least once + raise ValueError(f"Invalid {service_name} credentials. Verification failed (unknown reason after retries).") def get_credential(service, name, cred_type='credentials'): """ @@ -28,7 +107,7 @@ def get_credential(service, name, cred_type='credentials'): if service == 'deezer' and cred_type == 'search': raise ValueError("Search credentials are only supported for Spotify") - creds_dir = Path('./creds') / service / name + creds_dir = Path('./data/creds') / service / name file_path = creds_dir / f'{cred_type}.json' if not file_path.exists(): @@ -56,7 +135,7 @@ def list_credentials(service): if service not in ['spotify', 'deezer']: raise ValueError("Service must be 'spotify' or 'deezer'") - service_dir = Path('./creds') / service + service_dir = Path('./data/creds') / service if not service_dir.exists(): return [] @@ -116,21 +195,80 @@ def create_credential(service, name, data, cred_type='credentials'): raise ValueError(f"Missing required field for {cred_type}: {field}") # Create directory - creds_dir = Path('./creds') / service / name + creds_dir = Path('./data/creds') / service / name + file_created_now = False + dir_created_now = False + if cred_type == 'credentials': try: creds_dir.mkdir(parents=True, exist_ok=False) + dir_created_now = True except FileExistsError: - raise FileExistsError(f"Credential '{name}' already exists for {service}") - else: + # Directory already exists, which is fine for creating credentials.json + # if it doesn't exist yet, or if we are overwriting (though POST usually means new) + pass + except Exception as e: + raise ValueError(f"Could not create directory {creds_dir}: {e}") + + file_path = creds_dir / 'credentials.json' + if file_path.exists() and request.method == 'POST': # type: ignore + # Safety check for POST to not overwrite if file exists unless it's an edit (PUT) + raise FileExistsError(f"Credential file {file_path} already exists. Use PUT to modify.") + + # Write the credential file first + try: + with open(file_path, 'w') as f: + json.dump(data, f, indent=4) + file_created_now = True # Mark as created for potential cleanup + except Exception as e: + if dir_created_now: # Cleanup directory if file write failed + try: + creds_dir.rmdir() + except OSError: # rmdir fails if not empty, though it should be + pass + raise ValueError(f"Could not write credential file {file_path}: {e}") + + # --- Validation Step --- + try: + _validate_with_retry( + service_name=service, + account_name=name, + creds_dir_path=creds_dir, + cred_file_path=file_path, + data_for_validation=data, # 'data' contains the arl for Deezer + is_spotify=(service == 'spotify') + ) + except ValueError as val_err: # Catch the specific error from our helper + print(f"ERROR: Credential validation failed during creation for {service} account {name}: {val_err}") + traceback.print_exc() # Print full traceback here for creation failure context + # Clean up the created file and directory if validation fails + if file_created_now: + try: + file_path.unlink(missing_ok=True) + except OSError: + pass # Ignore if somehow already gone + if dir_created_now and not any(creds_dir.iterdir()): # Only remove if empty + try: + creds_dir.rmdir() + except OSError: + pass + raise # Re-raise the ValueError from validation + + elif cred_type == 'search': # Spotify only # For search.json, ensure the directory exists (it should if credentials.json exists) if not creds_dir.exists(): - raise FileNotFoundError(f"Credential '{name}' not found for {service}") - - # Write credentials file - file_path = creds_dir / f'{cred_type}.json' - with open(file_path, 'w') as f: - json.dump(data, f, indent=4) + # This implies credentials.json was not created first, which is an issue. + # However, the form logic might allow adding API creds to an existing empty dir. + # For now, let's create it if it's missing, assuming API creds can be standalone. + try: + creds_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + raise ValueError(f"Could not create directory for search credentials {creds_dir}: {e}") + + file_path = creds_dir / 'search.json' + # No specific validation for client_id/secret themselves, they are validated in use. + with open(file_path, 'w') as f: + json.dump(data, f, indent=4) def delete_credential(service, name, cred_type=None): """ @@ -145,7 +283,7 @@ def delete_credential(service, name, cred_type=None): Raises: FileNotFoundError: If the credential directory or specified file does not exist """ - creds_dir = Path('./creds') / service / name + creds_dir = Path('./data/creds') / service / name if cred_type: if cred_type not in ['credentials', 'search']: @@ -193,23 +331,29 @@ def edit_credential(service, name, new_data, cred_type='credentials'): raise ValueError("Search credentials are only supported for Spotify") # Get file path - creds_dir = Path('./creds') / service / name + creds_dir = Path('./data/creds') / service / name file_path = creds_dir / f'{cred_type}.json' - # For search.json, create if it doesn't exist - if cred_type == 'search' and not file_path.exists(): - if not creds_dir.exists(): - raise FileNotFoundError(f"Credential '{name}' not found for {service}") - data = {} - else: - # Load existing data - if not file_path.exists(): - raise FileNotFoundError(f"{cred_type.capitalize()} credential '{name}' not found for {service}") - + original_data_str = None # Store original data as string to revert + file_existed_before_edit = file_path.exists() + + if file_existed_before_edit: with open(file_path, 'r') as f: - data = json.load(f) - - # Validate new_data fields + original_data_str = f.read() + try: + data = json.loads(original_data_str) + except json.JSONDecodeError: + # If existing file is corrupt, treat as if we are creating it anew for edit + data = {} + original_data_str = None # Can't revert to corrupt data + else: + # If file doesn't exist, and we're editing (PUT), it's usually an error + # unless it's for search.json which can be created during an edit flow. + if cred_type == 'credentials': + raise FileNotFoundError(f"Cannot edit non-existent credentials file: {file_path}") + data = {} # Start with empty data for search.json creation + + # Validate new_data fields (data to be merged) allowed_fields = [] if cred_type == 'credentials': if service == 'spotify': @@ -223,15 +367,66 @@ def edit_credential(service, name, new_data, cred_type='credentials'): if key not in allowed_fields: raise ValueError(f"Invalid field '{key}' for {cred_type} credentials") - # Update data + # Update data (merging new_data into existing or empty data) data.update(new_data) - # For Deezer: Strip all fields except 'arl' + # --- Write and Validate Step for 'credentials' type --- + if cred_type == 'credentials': + try: + # Temporarily write new data for validation + with open(file_path, 'w') as f: + json.dump(data, f, indent=4) + + _validate_with_retry( + service_name=service, + account_name=name, + creds_dir_path=creds_dir, + cred_file_path=file_path, + data_for_validation=data, # 'data' is the merged data with 'arl' for Deezer + is_spotify=(service == 'spotify') + ) + except ValueError as val_err: # Catch the specific error from our helper + print(f"ERROR: Edited credential validation failed for {service} account {name}: {val_err}") + traceback.print_exc() # Print full traceback here for edit failure context + # Revert or delete the file + if original_data_str is not None: + with open(file_path, 'w') as f: + f.write(original_data_str) # Restore original content + elif file_existed_before_edit: # file existed but original_data_str is None (corrupt) + pass + else: # File didn't exist before this edit attempt, so remove it + try: + file_path.unlink(missing_ok=True) + except OSError: + pass # Ignore if somehow already gone + raise # Re-raise the ValueError from validation + except Exception as e: # Catch other potential errors like file IO during temp write + print(f"ERROR: Unexpected error during edit/validation for {service} account {name}: {e}") + traceback.print_exc() + # Attempt revert/delete + if original_data_str is not None: + with open(file_path, 'w') as f: f.write(original_data_str) + elif file_existed_before_edit: + pass + else: + try: + file_path.unlink(missing_ok=True) + except OSError: pass + raise ValueError(f"Failed to save edited {service} credentials due to: {str(e).splitlines()[-1]}") + + # For 'search' type, just write, no specific validation here for client_id/secret + elif cred_type == 'search': + if not creds_dir.exists(): # Should not happen if we're editing + raise FileNotFoundError(f"Credential directory {creds_dir} not found for editing search credentials.") + with open(file_path, 'w') as f: + json.dump(data, f, indent=4) # `data` here is the merged data for search + + # For Deezer: Strip all fields except 'arl' - This should use `data` which is `updated_data` if service == 'deezer' and cred_type == 'credentials': if 'arl' not in data: - raise ValueError("Missing 'arl' field for Deezer credential") + raise ValueError("Missing 'arl' field for Deezer credential after edit.") data = {'arl': data['arl']} - + # Ensure required fields are present required_fields = [] if cred_type == 'credentials': diff --git a/routes/utils/get_info.py b/routes/utils/get_info.py index 602801e..6f35bd1 100644 --- a/routes/utils/get_info.py +++ b/routes/utils/get_info.py @@ -29,7 +29,7 @@ def get_spotify_info(spotify_id, spotify_type): raise ValueError("No Spotify account configured in settings") if spotify_id: - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') if search_creds_path.exists(): try: with open(search_creds_path, 'r') as f: diff --git a/routes/utils/playlist.py b/routes/utils/playlist.py index 6830460..1b16df1 100755 --- a/routes/utils/playlist.py +++ b/routes/utils/playlist.py @@ -47,11 +47,11 @@ def download_playlist( # Smartly determine where to look for Spotify search credentials if service == 'spotify' and fallback: # If fallback is enabled, use the fallback account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{fallback}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json') print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}") else: # Otherwise use the main account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}") if search_creds_path.exists(): @@ -77,7 +77,7 @@ def download_playlist( deezer_error = None try: # Load Deezer credentials from 'main' under deezer directory - deezer_creds_dir = os.path.join('./creds/deezer', main) + deezer_creds_dir = os.path.join('./data/creds/deezer', main) deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json')) # DEBUG: Print Deezer credential paths being used @@ -124,7 +124,7 @@ def download_playlist( # Load fallback Spotify credentials and attempt download try: - spo_creds_dir = os.path.join('./creds/spotify', fallback) + spo_creds_dir = os.path.join('./data/creds/spotify', fallback) spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json')) print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}") @@ -168,7 +168,7 @@ def download_playlist( # Original behavior: use Spotify main if quality is None: quality = 'HIGH' - creds_dir = os.path.join('./creds/spotify', main) + creds_dir = os.path.join('./data/creds/spotify', main) credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) print(f"DEBUG: Using Spotify main credentials from: {credentials_path}") print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}") @@ -203,7 +203,7 @@ def download_playlist( if quality is None: quality = 'FLAC' # Existing code for Deezer, using main as Deezer account. - creds_dir = os.path.join('./creds/deezer', main) + creds_dir = os.path.join('./data/creds/deezer', main) creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) print(f"DEBUG: Using Deezer credentials from: {creds_path}") print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}") diff --git a/routes/utils/search.py b/routes/utils/search.py index 6ea5a0e..a0d20f6 100755 --- a/routes/utils/search.py +++ b/routes/utils/search.py @@ -19,7 +19,7 @@ def search( client_secret = None if main: - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') logger.debug(f"Looking for credentials at: {search_creds_path}") if search_creds_path.exists(): diff --git a/routes/utils/track.py b/routes/utils/track.py index ffa84aa..1ae1853 100755 --- a/routes/utils/track.py +++ b/routes/utils/track.py @@ -47,11 +47,11 @@ def download_track( # Smartly determine where to look for Spotify search credentials if service == 'spotify' and fallback: # If fallback is enabled, use the fallback account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{fallback}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json') print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}") else: # Otherwise use the main account for Spotify search credentials - search_creds_path = Path(f'./creds/spotify/{main}/search.json') + search_creds_path = Path(f'./data/creds/spotify/{main}/search.json') print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}") if search_creds_path.exists(): @@ -76,7 +76,7 @@ def download_track( # First attempt: use Deezer's download_trackspo with 'main' (Deezer credentials) deezer_error = None try: - deezer_creds_dir = os.path.join('./creds/deezer', main) + deezer_creds_dir = os.path.join('./data/creds/deezer', main) deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json')) # DEBUG: Print Deezer credential paths being used @@ -88,8 +88,8 @@ def download_track( # List available directories to compare print(f"DEBUG: Available Deezer credential directories:") - for dir_name in os.listdir('./creds/deezer'): - print(f"DEBUG: ./creds/deezer/{dir_name}") + for dir_name in os.listdir('./data/creds/deezer'): + print(f"DEBUG: ./data/creds/deezer/{dir_name}") with open(deezer_creds_path, 'r') as f: deezer_creds = json.load(f) @@ -122,7 +122,7 @@ def download_track( # If the first attempt fails, use the fallback Spotify credentials try: - spo_creds_dir = os.path.join('./creds/spotify', fallback) + spo_creds_dir = os.path.join('./data/creds/spotify', fallback) spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json')) # We've already loaded the Spotify client credentials above based on fallback @@ -159,7 +159,7 @@ def download_track( # Directly use Spotify main account if quality is None: quality = 'HIGH' - creds_dir = os.path.join('./creds/spotify', main) + creds_dir = os.path.join('./data/creds/spotify', main) credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) spo = SpoLogin( credentials_path=credentials_path, @@ -188,7 +188,7 @@ def download_track( if quality is None: quality = 'FLAC' # Deezer download logic remains unchanged, with the custom formatting parameters passed along. - creds_dir = os.path.join('./creds/deezer', main) + creds_dir = os.path.join('./data/creds/deezer', main) creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json')) with open(creds_path, 'r') as f: creds = json.load(f) diff --git a/src/js/config.ts b/src/js/config.ts index 2c13d56..86556b4 100644 --- a/src/js/config.ts +++ b/src/js/config.ts @@ -53,6 +53,24 @@ let isEditingSearch = false; let activeSpotifyAccount = ''; let activeDeezerAccount = ''; +// Reference to the credentials form card and add button +let credentialsFormCard: HTMLElement | null = null; +let showAddAccountFormBtn: HTMLElement | null = null; +let cancelAddAccountBtn: HTMLElement | null = null; + +// Helper function to manage visibility of form and add button +function setFormVisibility(showForm: boolean) { + if (credentialsFormCard && showAddAccountFormBtn) { + credentialsFormCard.style.display = showForm ? 'block' : 'none'; + showAddAccountFormBtn.style.display = showForm ? 'none' : 'flex'; // Assuming flex for styled button + if (showForm) { + resetForm(); // Reset form to "add new" state when showing for add + const credentialNameInput = document.getElementById('credentialName') as HTMLInputElement | null; + if(credentialNameInput) credentialNameInput.focus(); + } + } +} + async function loadConfig() { try { const response = await fetch('/api/config'); @@ -75,6 +93,8 @@ async function loadConfig() { // but updateAccountSelectors() will rebuild the options and set the proper values.) const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null; const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null; + const spotifyMessage = document.getElementById('spotifyAccountMessage') as HTMLElement | null; + const deezerMessage = document.getElementById('deezerAccountMessage') as HTMLElement | null; if (spotifySelect) spotifySelect.value = activeSpotifyAccount; if (deezerSelect) deezerSelect.value = activeDeezerAccount; @@ -115,6 +135,30 @@ document.addEventListener('DOMContentLoaded', async () => { setupServiceTabs(); setupEventListeners(); + // Setup for the collapsable "Add Account" form + credentialsFormCard = document.querySelector('.credentials-form.card'); + showAddAccountFormBtn = document.getElementById('showAddAccountFormBtn'); + cancelAddAccountBtn = document.getElementById('cancelAddAccountBtn'); + + if (credentialsFormCard && showAddAccountFormBtn) { + // Initially hide form, show add button (default state handled by setFormVisibility if called) + credentialsFormCard.style.display = 'none'; + showAddAccountFormBtn.style.display = 'flex'; // Assuming styled button uses flex + } + + if (showAddAccountFormBtn) { + showAddAccountFormBtn.addEventListener('click', () => { + setFormVisibility(true); + }); + } + + if (cancelAddAccountBtn && credentialsFormCard && showAddAccountFormBtn) { + cancelAddAccountBtn.addEventListener('click', () => { + setFormVisibility(false); + resetForm(); // Also reset form state on cancel + }); + } + const queueIcon = document.getElementById('queueIcon'); if (queueIcon) { queueIcon.addEventListener('click', () => { @@ -231,46 +275,65 @@ async function updateAccountSelectors() { // Get the select elements const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null; const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null; + const spotifyMessage = document.getElementById('spotifyAccountMessage') as HTMLElement | null; + const deezerMessage = document.getElementById('deezerAccountMessage') as HTMLElement | null; // Rebuild the Spotify selector options - if (spotifySelect) { - spotifySelect.innerHTML = spotifyAccounts - .map((a: string) => ``) - .join(''); + if (spotifySelect && spotifyMessage) { + if (spotifyAccounts.length > 0) { + spotifySelect.innerHTML = spotifyAccounts + .map((a: string) => ``) + .join(''); + spotifySelect.style.display = ''; + spotifyMessage.style.display = 'none'; - // Use the active account loaded from the config (activeSpotifyAccount) - if (spotifyAccounts.includes(activeSpotifyAccount)) { - spotifySelect.value = activeSpotifyAccount; - } else if (spotifyAccounts.length > 0) { - spotifySelect.value = spotifyAccounts[0]; - activeSpotifyAccount = spotifyAccounts[0]; - await saveConfig(); + // Use the active account loaded from the config (activeSpotifyAccount) + if (activeSpotifyAccount && spotifyAccounts.includes(activeSpotifyAccount)) { + spotifySelect.value = activeSpotifyAccount; + } else { + spotifySelect.value = spotifyAccounts[0]; + activeSpotifyAccount = spotifyAccounts[0]; + await saveConfig(); // Save if we defaulted + } + } else { + spotifySelect.innerHTML = ''; + spotifySelect.style.display = 'none'; + spotifyMessage.textContent = 'No Spotify accounts available.'; + spotifyMessage.style.display = ''; + if (activeSpotifyAccount !== '') { // Clear active account if it was set + activeSpotifyAccount = ''; + await saveConfig(); + } } } // Rebuild the Deezer selector options - if (deezerSelect) { - deezerSelect.innerHTML = deezerAccounts - .map((a: string) => ``) - .join(''); + if (deezerSelect && deezerMessage) { + if (deezerAccounts.length > 0) { + deezerSelect.innerHTML = deezerAccounts + .map((a: string) => ``) + .join(''); + deezerSelect.style.display = ''; + deezerMessage.style.display = 'none'; - if (deezerAccounts.includes(activeDeezerAccount)) { - deezerSelect.value = activeDeezerAccount; - } else if (deezerAccounts.length > 0) { - deezerSelect.value = deezerAccounts[0]; - activeDeezerAccount = deezerAccounts[0]; - await saveConfig(); + if (activeDeezerAccount && deezerAccounts.includes(activeDeezerAccount)) { + deezerSelect.value = activeDeezerAccount; + } else { + deezerSelect.value = deezerAccounts[0]; + activeDeezerAccount = deezerAccounts[0]; + await saveConfig(); // Save if we defaulted + } + } else { + deezerSelect.innerHTML = ''; + deezerSelect.style.display = 'none'; + deezerMessage.textContent = 'No Deezer accounts available.'; + deezerMessage.style.display = ''; + if (activeDeezerAccount !== '') { // Clear active account if it was set + activeDeezerAccount = ''; + await saveConfig(); + } } } - - // Handle empty account lists - [spotifySelect, deezerSelect].forEach((select, index) => { - const accounts = index === 0 ? spotifyAccounts : deezerAccounts; - if (select && accounts.length === 0) { - select.innerHTML = ''; - select.value = ''; - } - }); } catch (error: any) { showConfigError('Error updating accounts: ' + error.message); } @@ -291,7 +354,7 @@ async function loadCredentials(service: string) { } function renderCredentialsList(service: string, credentials: any[]) { - const list = document.querySelector('.credentials-list') as HTMLElement | null; + const list = document.querySelector('.credentials-list-items') as HTMLElement | null; if (!list) return; list.innerHTML = ''; @@ -396,6 +459,8 @@ async function handleEditCredential(e: MouseEvent) { (document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click(); await new Promise(resolve => setTimeout(resolve, 50)); + setFormVisibility(true); // Show form for editing, will hide add button + const response = await fetch(`/api/credentials/${service}/${name}`); if (!response.ok) { throw new Error(`Failed to load credential: ${response.statusText}`); @@ -430,6 +495,8 @@ async function handleEditSearchCredential(e: Event) { throw new Error('Search credentials are only available for Spotify'); } + setFormVisibility(true); // Show form for editing search creds, will hide add button + (document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click(); await new Promise(resolve => setTimeout(resolve, 50)); @@ -662,7 +729,7 @@ async function handleCredentialSubmit(e: Event) { await updateAccountSelectors(); await saveConfig(); loadCredentials(service!); - resetForm(); + setFormVisibility(false); // Hide form and show add button on successful submission // Show success message showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully'); diff --git a/src/js/queue.ts b/src/js/queue.ts index f7d8d2f..828124e 100644 --- a/src/js/queue.ts +++ b/src/js/queue.ts @@ -215,7 +215,7 @@ export class DownloadQueue {

Download Queue (0 items)

@@ -249,7 +249,7 @@ export class DownloadQueue { const queueIcon = document.getElementById('queueIcon'); if (queueIcon && this.config) { if (this.config.downloadQueueVisible) { - queueIcon.innerHTML = '×'; + queueIcon.innerHTML = 'Close queue'; queueIcon.setAttribute('aria-expanded', 'true'); queueIcon.classList.add('queue-icon-active'); // Add red tint class } else { @@ -329,7 +329,7 @@ export class DownloadQueue { if (queueIcon && this.config) { if (isVisible) { // Replace the image with an X and add red tint - queueIcon.innerHTML = '×'; + queueIcon.innerHTML = 'Close queue'; queueIcon.setAttribute('aria-expanded', 'true'); queueIcon.classList.add('queue-icon-active'); // Add red tint class } else { @@ -356,13 +356,13 @@ export class DownloadQueue { // Also revert the icon back if (queueIcon && this.config) { if (!isVisible) { - queueIcon.innerHTML = '×'; + queueIcon.innerHTML = 'Close queue'; queueIcon.setAttribute('aria-expanded', 'true'); queueIcon.classList.add('queue-icon-active'); // Add red tint class } else { - queueIcon.innerHTML = 'Queue Icon'; - queueIcon.setAttribute('aria-expanded', 'false'); - queueIcon.classList.remove('queue-icon-active'); // Remove red tint class + queueIcon.innerHTML = 'Close queue'; + queueIcon.setAttribute('aria-expanded', 'true'); + queueIcon.classList.add('queue-icon-active'); // Add red tint class } } this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible }); @@ -658,7 +658,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
${displayType}
@@ -2179,7 +2179,9 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string) errorLogElement.innerHTML = `
${errMsg}
- +
`; diff --git a/static/css/config/config.css b/static/css/config/config.css index 23e7b57..36ab501 100644 --- a/static/css/config/config.css +++ b/static/css/config/config.css @@ -928,3 +928,78 @@ input:checked + .slider:before { opacity: 1; transform: translateY(0); } + +/* Credentials List Wrapper */ +.credentials-list-wrapper { + background: #181818; /* Same as original .credentials-list.card */ + border-radius: 12px; + box-shadow: 0 4px 8px rgba(0,0,0,0.15); + padding: 1.5rem; /* Add padding here if you want it around the whole block */ + margin-bottom: 2rem; +} + +/* Where individual credential items will be rendered */ +.credentials-list-items { + /* No specific styles needed here unless items need separation from the add button */ +} + +/* Styling for the Add New Account button to make it look like a list item */ +.add-account-item { + margin-top: 0.75rem; /* Space above the add button if there are items */ +} + +.btn-add-account-styled { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 1.25rem; + background-color: #1db954; /* Green background */ + color: #ffffff; + border: none; + border-radius: 8px; /* Same as credential-item */ + font-size: 1.1rem; /* Similar to credential-name */ + font-weight: 600; + cursor: pointer; + transition: background-color 0.3s ease, transform 0.2s ease; + text-align: center; + opacity: 1; /* Ensure it's not transparent by default */ +} + +.btn-add-account-styled img { + width: 20px; /* Adjust as needed */ + height: 20px; /* Adjust as needed */ + margin-right: 10px; + filter: brightness(0) invert(1); /* Make icon white if it's not already */ +} + +.btn-add-account-styled:hover { + background-color: #1aa34a; /* Darker green on hover */ + transform: translateY(-1px); +} + +/* New styles for the icon-based cancel button */ +.btn-cancel-icon { + background-color: #c0392b !important; /* Red background */ + padding: 0.6rem !important; /* Adjust padding for icon */ + width: auto; /* Allow button to size to icon */ + min-width: 40px; /* Ensure a minimum touch target size */ + height: 40px; /* Ensure a minimum touch target size */ + display: flex; + align-items: center; + justify-content: center; + border-radius: 50% !important; /* Make it circular */ + opacity: 1 !important; /* Ensure it's always visible when its container is */ + visibility: visible !important; /* Ensure it's not hidden by visibility property */ +} + +.btn-cancel-icon img { + width: 16px; /* Adjust icon size as needed */ + height: 16px; + filter: brightness(0) invert(1); /* Make icon white */ +} + +.btn-cancel-icon:hover { + background-color: #e74c3c !important; /* Lighter red on hover */ + transform: translateY(-2px) scale(1.05); +} diff --git a/static/images/cross.svg b/static/images/cross.svg new file mode 100644 index 0000000..cc2cd18 --- /dev/null +++ b/static/images/cross.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/plus-circle.svg b/static/images/plus-circle.svg new file mode 100644 index 0000000..05357df --- /dev/null +++ b/static/images/plus-circle.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/skull-head.svg b/static/images/skull-head.svg new file mode 100644 index 0000000..96b0922 --- /dev/null +++ b/static/images/skull-head.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/templates/config.html b/templates/config.html index 7cd30b7..2f6c857 100644 --- a/templates/config.html +++ b/templates/config.html @@ -32,6 +32,7 @@
+
@@ -44,6 +45,7 @@
+
@@ -229,7 +231,18 @@
-
+ +
+
+ + +
+ +

Add New Spotify Account

@@ -241,6 +254,9 @@
+