From a4932ae36ed161bd20eadf6df6bc517acf99bbdd Mon Sep 17 00:00:00 2001 From: "cool.gitter.choco" Date: Sat, 15 Mar 2025 14:44:43 -0600 Subject: [PATCH] meh --- .gitignore | 4 + app.py | 23 +- routes/album.py | 78 ++- routes/artist.py | 96 +-- routes/config.py | 24 + routes/playlist.py | 76 ++- routes/prgs.py | 39 +- routes/search.py | 10 +- routes/track.py | 76 ++- routes/utils/album.py | 30 +- routes/utils/artist.py | 10 +- routes/utils/get_info.py | 33 +- routes/utils/playlist.py | 30 +- routes/utils/queue.py | 1194 +++++++++++++++++++++++++++++---- routes/utils/search.py | 4 +- routes/utils/track.py | 29 +- static/css/config/config.css | 18 + static/images/placeholder.jpg | Bin 0 -> 5089 bytes static/js/album.js | 156 ++--- static/js/artist.js | 64 +- static/js/config.js | 88 ++- static/js/main.js | 167 +++-- static/js/playlist.js | 215 +++--- static/js/queue.js | 355 +++++++--- static/js/track.js | 125 ++-- templates/album.html | 2 +- templates/artist.html | 2 +- templates/config.html | 28 + templates/main.html | 10 +- templates/playlist.html | 2 +- templates/track.html | 2 +- 31 files changed, 2183 insertions(+), 807 deletions(-) create mode 100644 static/images/placeholder.jpg diff --git a/.gitignore b/.gitignore index 0914cfb..cf9af69 100755 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ routes/utils/__pycache__/search.cpython-312.pyc search_test.py config/main.json .cache +config/state/queue_state.json +output.log +queue_state.json +search_demo.py diff --git a/app.py b/app.py index a8f07bc..31eccc0 100755 --- a/app.py +++ b/app.py @@ -106,19 +106,12 @@ def create_app(): return app -app = create_app() - if __name__ == '__main__': - import os - - DEBUG = os.getenv("FLASK_DEBUG", "0") == "1" - - if DEBUG: - logging.info("Starting Flask in DEBUG mode on port 7171") - app.run(debug=True, host='0.0.0.0', port=7171) # Use Flask's built-in server - else: - logger = logging.getLogger('waitress') - logger.setLevel(logging.INFO) - logging.info("Starting Flask server with Waitress on port 7171") - serve(app, host='0.0.0.0', port=7171) # Use Waitress for production - + # Configure waitress logger + logger = logging.getLogger('waitress') + logger.setLevel(logging.INFO) + + app = create_app() + logging.info("Starting Flask server on port 7171") + from waitress import serve + serve(app, host='0.0.0.0', port=7171) diff --git a/routes/album.py b/routes/album.py index d72fc38..ce4f3a8 100755 --- a/routes/album.py +++ b/routes/album.py @@ -2,27 +2,60 @@ from flask import Blueprint, Response, request import json import os import traceback -from routes.utils.queue import download_queue_manager +from routes.utils.queue import download_queue_manager, get_config_params album_bp = Blueprint('album', __name__) @album_bp.route('/download', methods=['GET']) def handle_download(): - # Retrieve parameters from the request. + # Retrieve essential parameters from the request. service = request.args.get('service') url = request.args.get('url') + + # Get common parameters from config + config_params = get_config_params() + + # Allow request parameters to override config values main = request.args.get('main') fallback = request.args.get('fallback') quality = request.args.get('quality') fall_quality = request.args.get('fall_quality') + real_time_arg = request.args.get('real_time') + custom_dir_format = request.args.get('custom_dir_format') + custom_track_format = request.args.get('custom_track_format') + pad_tracks_arg = request.args.get('tracknum_padding') - # Normalize the real_time parameter; default to False. - real_time_arg = request.args.get('real_time', 'false') - real_time = real_time_arg.lower() in ['true', '1', 'yes'] - - # New custom formatting parameters (with defaults) - custom_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%") - custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%") + # Use config values as defaults when parameters are not provided + if not main: + main = config_params['spotify'] if service == 'spotify' else config_params['deezer'] + + if not fallback and config_params['fallback'] and service == 'spotify': + fallback = config_params['spotify'] + + if not quality: + quality = config_params['spotifyQuality'] if service == 'spotify' else config_params['deezerQuality'] + + if not fall_quality and fallback: + fall_quality = config_params['spotifyQuality'] + + # Parse boolean parameters + real_time = real_time_arg.lower() in ['true', '1', 'yes'] if real_time_arg is not None else config_params['realTime'] + pad_tracks = pad_tracks_arg.lower() in ['true', '1', 'yes'] if pad_tracks_arg is not None else config_params['tracknum_padding'] + + # Use config values for formatting if not provided + if not custom_dir_format: + custom_dir_format = config_params['customDirFormat'] + + if not custom_track_format: + custom_track_format = config_params['customTrackFormat'] + + # Validate required parameters + if not all([service, url, main]): + return Response( + json.dumps({"error": "Missing parameters: service, url, or main account"}), + status=400, + mimetype='application/json' + ) # Sanitize main and fallback to prevent directory traversal. if main: @@ -30,13 +63,6 @@ def handle_download(): if fallback: fallback = os.path.basename(fallback) - if not all([service, url, main]): - return Response( - json.dumps({"error": "Missing parameters"}), - status=400, - mimetype='application/json' - ) - # Validate credentials based on service and fallback. try: if service == 'spotify': @@ -101,6 +127,7 @@ def handle_download(): "real_time": real_time, "custom_dir_format": custom_dir_format, "custom_track_format": custom_track_format, + "pad_tracks": pad_tracks, "orig_request": request.args.to_dict(), # New additional parameters: "type": "album", @@ -147,7 +174,6 @@ def get_album_info(): Expects a query parameter 'id' that contains the Spotify album ID. """ spotify_id = request.args.get('id') - main = request.args.get('main', '') if not spotify_id: return Response( @@ -156,26 +182,10 @@ def get_album_info(): mimetype='application/json' ) - # If main parameter is not provided in the request, get it from config - if not main: - from routes.config import get_config - config = get_config() - if config and 'spotify' in config: - main = config['spotify'] - print(f"Using main from config for album info: {main}") - - # Validate main parameter - if not main: - return Response( - json.dumps({"error": "Missing parameter: main (Spotify account)"}), - status=400, - mimetype='application/json' - ) - try: # Import and use the get_spotify_info function from the utility module. from routes.utils.get_info import get_spotify_info - album_info = get_spotify_info(spotify_id, "album", main=main) + album_info = get_spotify_info(spotify_id, "album") return Response( json.dumps(album_info), status=200, diff --git a/routes/artist.py b/routes/artist.py index e5764e5..18654ad 100644 --- a/routes/artist.py +++ b/routes/artist.py @@ -9,6 +9,7 @@ import os import random import string import traceback +from routes.utils.queue import download_queue_manager, get_config_params artist_bp = Blueprint('artist', __name__) @@ -23,32 +24,61 @@ def handle_artist_download(): Expected query parameters: - url: string (a Spotify artist URL) - service: string ("spotify" or "deezer") - - main: string (e.g., a credentials directory name) - - fallback: string (optional) - - quality: string (e.g., "MP3_128") - - fall_quality: string (optional, e.g., "HIGH") - - real_time: bool (e.g., "true" or "false") - album_type: string(s); comma-separated values such as "album,single,appears_on,compilation" - - custom_dir_format: string (optional, default: "%ar_album%/%album%/%copyright%") - - custom_track_format: string (optional, default: "%tracknum%. %music% - %artist%") - - Since the new download_artist_albums() function simply enqueues album tasks via - the global queue manager, it returns a list of album PRG filenames. These are sent - back immediately in the JSON response. """ + # Retrieve essential parameters from the request. service = request.args.get('service') url = request.args.get('url') + album_type = request.args.get('album_type') + + # Get common parameters from config + config_params = get_config_params() + + # Allow request parameters to override config values main = request.args.get('main') fallback = request.args.get('fallback') quality = request.args.get('quality') fall_quality = request.args.get('fall_quality') - album_type = request.args.get('album_type') - real_time_arg = request.args.get('real_time', 'false') - real_time = real_time_arg.lower() in ['true', '1', 'yes'] - - # New query parameters for custom formatting. - custom_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%/%copyright%") - custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%") + real_time_arg = request.args.get('real_time') + custom_dir_format = request.args.get('custom_dir_format') + custom_track_format = request.args.get('custom_track_format') + pad_tracks_arg = request.args.get('tracknum_padding') + + # Use config values as defaults when parameters are not provided + if not main: + main = config_params['spotify'] if service == 'spotify' else config_params['deezer'] + + if not fallback and config_params['fallback'] and service == 'spotify': + fallback = config_params['spotify'] + + if not quality: + quality = config_params['spotifyQuality'] if service == 'spotify' else config_params['deezerQuality'] + + if not fall_quality and fallback: + fall_quality = config_params['spotifyQuality'] + + # Parse boolean parameters + real_time = real_time_arg.lower() in ['true', '1', 'yes'] if real_time_arg is not None else config_params['realTime'] + pad_tracks = pad_tracks_arg.lower() in ['true', '1', 'yes'] if pad_tracks_arg is not None else config_params['tracknum_padding'] + + # Use config values for formatting if not provided + if not custom_dir_format: + custom_dir_format = config_params['customDirFormat'] + + if not custom_track_format: + custom_track_format = config_params['customTrackFormat'] + + # Use default album_type if not specified + if not album_type: + album_type = "album,single,compilation" + + # Validate required parameters + if not all([service, url, main, quality]): + return Response( + json.dumps({"error": "Missing parameters: service, url, main, or quality"}), + status=400, + mimetype='application/json' + ) # Sanitize main and fallback to prevent directory traversal. if main: @@ -56,14 +86,6 @@ def handle_artist_download(): if fallback: fallback = os.path.basename(fallback) - # Check for required parameters. - if not all([service, url, main, quality, album_type]): - return Response( - json.dumps({"error": "Missing parameters"}), - status=400, - mimetype='application/json' - ) - # Validate credentials based on the selected service. try: if service == 'spotify': @@ -125,7 +147,8 @@ def handle_artist_download(): real_time=real_time, album_type=album_type, custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format + custom_track_format=custom_track_format, + pad_tracks=pad_tracks ) # Return the list of album PRG filenames. return Response( @@ -169,7 +192,6 @@ def get_artist_info(): Expects a query parameter 'id' with the Spotify artist ID. """ spotify_id = request.args.get('id') - main = request.args.get('main', '') if not spotify_id: return Response( @@ -178,25 +200,9 @@ def get_artist_info(): mimetype='application/json' ) - # If main parameter is not provided in the request, get it from config - if not main: - from routes.config import get_config - config = get_config() - if config and 'spotify' in config: - main = config['spotify'] - print(f"Using main from config for artist info: {main}") - - # Validate main parameter - if not main: - return Response( - json.dumps({"error": "Missing parameter: main (Spotify account)"}), - status=400, - mimetype='application/json' - ) - try: from routes.utils.get_info import get_spotify_info - artist_info = get_spotify_info(spotify_id, "artist", main=main) + artist_info = get_spotify_info(spotify_id, "artist") return Response( json.dumps(artist_info), status=200, diff --git a/routes/config.py b/routes/config.py index cba627b..7cbadea 100644 --- a/routes/config.py +++ b/routes/config.py @@ -24,6 +24,30 @@ def handle_config(): config = get_config() if config is None: return jsonify({"error": "Could not read config file"}), 500 + + # Create config/state directory + Path('./config/state').mkdir(parents=True, exist_ok=True) + + # Set default values for any missing config options + defaults = { + '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 + return jsonify(config) @config_bp.route('/config', methods=['POST', 'PUT']) diff --git a/routes/playlist.py b/routes/playlist.py index 4b65152..28523bf 100755 --- a/routes/playlist.py +++ b/routes/playlist.py @@ -2,27 +2,60 @@ from flask import Blueprint, Response, request import os import json import traceback -from routes.utils.queue import download_queue_manager +from routes.utils.queue import download_queue_manager, get_config_params playlist_bp = Blueprint('playlist', __name__) @playlist_bp.route('/download', methods=['GET']) def handle_download(): - # Retrieve parameters from the request. + # Retrieve essential parameters from the request. service = request.args.get('service') url = request.args.get('url') + + # Get common parameters from config + config_params = get_config_params() + + # Allow request parameters to override config values main = request.args.get('main') fallback = request.args.get('fallback') quality = request.args.get('quality') fall_quality = request.args.get('fall_quality') + real_time_arg = request.args.get('real_time') + custom_dir_format = request.args.get('custom_dir_format') + custom_track_format = request.args.get('custom_track_format') + pad_tracks_arg = request.args.get('tracknum_padding') - # Normalize the real_time parameter; default to False. - real_time_arg = request.args.get('real_time', 'false') - real_time = real_time_arg.lower() in ['true', '1', 'yes'] + # Use config values as defaults when parameters are not provided + if not main: + main = config_params['spotify'] if service == 'spotify' else config_params['deezer'] - # New custom formatting parameters (with defaults) - custom_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%/%copyright%") - custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%") + if not fallback and config_params['fallback'] and service == 'spotify': + fallback = config_params['spotify'] + + if not quality: + quality = config_params['spotifyQuality'] if service == 'spotify' else config_params['deezerQuality'] + + if not fall_quality and fallback: + fall_quality = config_params['spotifyQuality'] + + # Parse boolean parameters + real_time = real_time_arg.lower() in ['true', '1', 'yes'] if real_time_arg is not None else config_params['realTime'] + pad_tracks = pad_tracks_arg.lower() in ['true', '1', 'yes'] if pad_tracks_arg is not None else config_params['tracknum_padding'] + + # Use config values for formatting if not provided + if not custom_dir_format: + custom_dir_format = config_params['customDirFormat'] + + if not custom_track_format: + custom_track_format = config_params['customTrackFormat'] + + # Validate required parameters + if not all([service, url, main]): + return Response( + json.dumps({"error": "Missing parameters: service, url, or main account"}), + status=400, + mimetype='application/json' + ) # Sanitize main and fallback to prevent directory traversal. if main: @@ -30,13 +63,6 @@ def handle_download(): if fallback: fallback = os.path.basename(fallback) - if not all([service, url, main]): - return Response( - json.dumps({"error": "Missing parameters"}), - status=400, - mimetype='application/json' - ) - # Build the task dictionary. # Note: the key "download_type" tells the queue handler which download function to call. task = { @@ -50,6 +76,7 @@ def handle_download(): "real_time": real_time, "custom_dir_format": custom_dir_format, "custom_track_format": custom_track_format, + "pad_tracks": pad_tracks, "orig_request": request.args.to_dict(), # If provided, these additional parameters can be used by your download function. "type": "playlist", @@ -96,7 +123,6 @@ def get_playlist_info(): Expects a query parameter 'id' that contains the Spotify playlist ID. """ spotify_id = request.args.get('id') - main = request.args.get('main', '') if not spotify_id: return Response( @@ -105,26 +131,10 @@ def get_playlist_info(): mimetype='application/json' ) - # If main parameter is not provided in the request, get it from config - if not main: - from routes.config import get_config - config = get_config() - if config and 'spotify' in config: - main = config['spotify'] - print(f"Using main from config for playlist info: {main}") - - # Validate main parameter - if not main: - return Response( - json.dumps({"error": "Missing parameter: main (Spotify account)"}), - status=400, - mimetype='application/json' - ) - try: # Import and use the get_spotify_info function from the utility module. from routes.utils.get_info import get_spotify_info - playlist_info = get_spotify_info(spotify_id, "playlist", main=main) + playlist_info = get_spotify_info(spotify_id, "playlist") return Response( json.dumps(playlist_info), status=200, diff --git a/routes/prgs.py b/routes/prgs.py index 78bd370..7a99263 100755 --- a/routes/prgs.py +++ b/routes/prgs.py @@ -32,32 +32,57 @@ def get_prg_file(filename): return jsonify({ "type": "", "name": "", + "artist": "", "last_line": None, - "original_request": None + "original_request": None, + "display_title": "", + "display_type": "", + "display_artist": "" }) # Attempt to extract the original request from the first line. original_request = None + display_title = "" + display_type = "" + display_artist = "" + try: first_line = json.loads(lines[0]) - if "original_request" in first_line: - original_request = first_line["original_request"] - except Exception: + if isinstance(first_line, dict): + if "original_request" in first_line: + original_request = first_line["original_request"] + else: + # The first line might be the original request itself + original_request = first_line + + # Extract display information from the original request + if original_request: + display_title = original_request.get("display_title", original_request.get("name", "")) + display_type = original_request.get("display_type", original_request.get("type", "")) + display_artist = original_request.get("display_artist", original_request.get("artist", "")) + except Exception as e: + print(f"Error parsing first line of PRG file: {e}") original_request = None # For resource type and name, use the second line if available. + resource_type = "" + resource_name = "" + resource_artist = "" if len(lines) > 1: try: second_line = json.loads(lines[1]) # Directly extract 'type' and 'name' from the JSON resource_type = second_line.get("type", "") resource_name = second_line.get("name", "") + resource_artist = second_line.get("artist", "") except Exception: resource_type = "" resource_name = "" + resource_artist = "" else: resource_type = "" resource_name = "" + resource_artist = "" # Get the last line from the file. last_line_raw = lines[-1] @@ -69,8 +94,12 @@ def get_prg_file(filename): return jsonify({ "type": resource_type, "name": resource_name, + "artist": resource_artist, "last_line": last_line_parsed, - "original_request": original_request + "original_request": original_request, + "display_title": display_title, + "display_type": display_type, + "display_artist": display_artist }) except FileNotFoundError: abort(404, "File not found") diff --git a/routes/search.py b/routes/search.py index 7e90635..1d31f38 100755 --- a/routes/search.py +++ b/routes/search.py @@ -21,7 +21,6 @@ def handle_search(): main = config['spotify'] print(f"Using main from config: {main}") - print(f"Search request: query={query}, type={search_type}, limit={limit}, main={main}") # Validate parameters if not query: @@ -39,21 +38,16 @@ def handle_search(): main=main # Pass the main parameter ) - print(f"Search response keys: {raw_results.keys() if raw_results else 'None'}") - + # Extract items from the appropriate section of the response based on search_type items = [] if raw_results and search_type + 's' in raw_results: - # Handle plural form (e.g., 'tracks' instead of 'track') type_key = search_type + 's' - print(f"Using type key: {type_key}") items = raw_results[type_key].get('items', []) elif raw_results and search_type in raw_results: - # Handle singular form - print(f"Using type key: {search_type}") + items = raw_results[search_type].get('items', []) - print(f"Found {len(items)} items") # Return both the items array and the full data for debugging return jsonify({ diff --git a/routes/track.py b/routes/track.py index 32f6453..13c1f52 100755 --- a/routes/track.py +++ b/routes/track.py @@ -2,27 +2,60 @@ from flask import Blueprint, Response, request import os import json import traceback -from routes.utils.queue import download_queue_manager +from routes.utils.queue import download_queue_manager, get_config_params track_bp = Blueprint('track', __name__) @track_bp.route('/download', methods=['GET']) def handle_download(): - # Retrieve parameters from the request. + # Retrieve essential parameters from the request. service = request.args.get('service') url = request.args.get('url') + + # Get common parameters from config + config_params = get_config_params() + + # Allow request parameters to override config values main = request.args.get('main') fallback = request.args.get('fallback') quality = request.args.get('quality') fall_quality = request.args.get('fall_quality') + real_time_arg = request.args.get('real_time') + custom_dir_format = request.args.get('custom_dir_format') + custom_track_format = request.args.get('custom_track_format') + pad_tracks_arg = request.args.get('tracknum_padding') - # Normalize the real_time parameter; default to False. - real_time_arg = request.args.get('real_time', 'false') - real_time = real_time_arg.lower() in ['true', '1', 'yes'] + # Use config values as defaults when parameters are not provided + if not main: + main = config_params['spotify'] if service == 'spotify' else config_params['deezer'] - # New custom formatting parameters (with defaults). - custom_dir_format = request.args.get('custom_dir_format', "%ar_album%/%album%/%copyright%") - custom_track_format = request.args.get('custom_track_format', "%tracknum%. %music% - %artist%") + if not fallback and config_params['fallback'] and service == 'spotify': + fallback = config_params['spotify'] + + if not quality: + quality = config_params['spotifyQuality'] if service == 'spotify' else config_params['deezerQuality'] + + if not fall_quality and fallback: + fall_quality = config_params['spotifyQuality'] + + # Parse boolean parameters + real_time = real_time_arg.lower() in ['true', '1', 'yes'] if real_time_arg is not None else config_params['realTime'] + pad_tracks = pad_tracks_arg.lower() in ['true', '1', 'yes'] if pad_tracks_arg is not None else config_params['tracknum_padding'] + + # Use config values for formatting if not provided + if not custom_dir_format: + custom_dir_format = config_params['customDirFormat'] + + if not custom_track_format: + custom_track_format = config_params['customTrackFormat'] + + # Validate required parameters + if not all([service, url, main]): + return Response( + json.dumps({"error": "Missing parameters: service, url, or main account"}), + status=400, + mimetype='application/json' + ) # Sanitize main and fallback to prevent directory traversal. if main: @@ -30,13 +63,6 @@ def handle_download(): if fallback: fallback = os.path.basename(fallback) - if not all([service, url, main]): - return Response( - json.dumps({"error": "Missing parameters"}), - status=400, - mimetype='application/json' - ) - # Validate credentials based on service and fallback. try: if service == 'spotify': @@ -103,6 +129,7 @@ def handle_download(): "real_time": real_time, "custom_dir_format": custom_dir_format, "custom_track_format": custom_track_format, + "pad_tracks": pad_tracks, "orig_request": orig_request, # Additional parameters if needed. "type": "track", @@ -149,7 +176,6 @@ def get_track_info(): Expects a query parameter 'id' that contains the Spotify track ID. """ spotify_id = request.args.get('id') - main = request.args.get('main', '') if not spotify_id: return Response( @@ -158,26 +184,10 @@ def get_track_info(): mimetype='application/json' ) - # If main parameter is not provided in the request, get it from config - if not main: - from routes.config import get_config - config = get_config() - if config and 'spotify' in config: - main = config['spotify'] - print(f"Using main from config for track info: {main}") - - # Validate main parameter - if not main: - return Response( - json.dumps({"error": "Missing parameter: main (Spotify account)"}), - status=400, - mimetype='application/json' - ) - try: # Import and use the get_spotify_info function from the utility module. from routes.utils.get_info import get_spotify_info - track_info = get_spotify_info(spotify_id, "track", main=main) + track_info = get_spotify_info(spotify_id, "track") return Response( json.dumps(track_info), status=200, diff --git a/routes/utils/album.py b/routes/utils/album.py index af8a804..786d376 100755 --- a/routes/utils/album.py +++ b/routes/utils/album.py @@ -14,7 +14,11 @@ def download_album( fall_quality=None, real_time=False, custom_dir_format="%ar_album%/%album%/%copyright%", - custom_track_format="%tracknum%. %music% - %artist%" + custom_track_format="%tracknum%. %music% - %artist%", + pad_tracks=True, + initial_retry_delay=5, + retry_delay_increase=5, + max_retries=3 ): try: # Load Spotify client credentials if available @@ -60,7 +64,11 @@ def download_album( make_zip=False, method_save=1, custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries ) except Exception as e: # Load fallback Spotify credentials and attempt download @@ -97,7 +105,11 @@ def download_album( make_zip=False, real_time_dl=real_time, custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries ) except Exception as e2: # If fallback also fails, raise an error indicating both attempts failed @@ -127,7 +139,11 @@ def download_album( make_zip=False, real_time_dl=real_time, custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries ) elif service == 'deezer': if quality is None: @@ -151,7 +167,11 @@ def download_album( method_save=1, make_zip=False, custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries ) else: raise ValueError(f"Unsupported service: {service}") diff --git a/routes/utils/artist.py b/routes/utils/artist.py index 8a3af38..ba5ddf3 100644 --- a/routes/utils/artist.py +++ b/routes/utils/artist.py @@ -63,7 +63,11 @@ def download_artist_albums(service, url, main, fallback=None, quality=None, fall_quality=None, real_time=False, album_type='album,single,compilation,appears_on', custom_dir_format="%ar_album%/%album%/%copyright%", - custom_track_format="%tracknum%. %music% - %artist%"): + custom_track_format="%tracknum%. %music% - %artist%", + pad_tracks=True, + initial_retry_delay=5, + retry_delay_increase=5, + max_retries=3): """ Retrieves the artist discography and, for each album with a valid Spotify URL, creates a download task that is queued via the global download queue. The queue @@ -110,6 +114,10 @@ def download_artist_albums(service, url, main, fallback=None, quality=None, "real_time": real_time, "custom_dir_format": custom_dir_format, "custom_track_format": custom_track_format, + "pad_tracks": pad_tracks, + "initial_retry_delay": initial_retry_delay, + "retry_delay_increase": retry_delay_increase, + "max_retries": max_retries, # Extra info for logging in the PRG file. "name": album_name, "type": "album", diff --git a/routes/utils/get_info.py b/routes/utils/get_info.py index 9375dd5..6aaf656 100644 --- a/routes/utils/get_info.py +++ b/routes/utils/get_info.py @@ -4,20 +4,45 @@ from deezspot.easy_spoty import Spo import json from pathlib import Path -def get_spotify_info(spotify_id, spotify_type, main=None): +# Load configuration from ./config/main.json +CONFIG_PATH = './config/main.json' +try: + with open(CONFIG_PATH, 'r') as f: + config_data = json.load(f) + # Get the main Spotify account from config + DEFAULT_SPOTIFY_ACCOUNT = config_data.get("spotify", "") +except Exception as e: + print(f"Error loading configuration: {e}") + DEFAULT_SPOTIFY_ACCOUNT = "" + +def get_spotify_info(spotify_id, spotify_type): + """ + Get info from Spotify API using the default Spotify account configured in main.json + + Args: + spotify_id: The Spotify ID of the entity + spotify_type: The type of entity (track, album, playlist, artist) + + Returns: + Dictionary with the entity information + """ client_id = None client_secret = None + + # Use the default account from config + main = DEFAULT_SPOTIFY_ACCOUNT + + if not main: + raise ValueError("No Spotify account configured in settings") + if spotify_id: search_creds_path = Path(f'./creds/spotify/{main}/search.json') - print(search_creds_path) if search_creds_path.exists(): try: with open(search_creds_path, 'r') as f: search_creds = json.load(f) client_id = search_creds.get('client_id') - print(client_id) client_secret = search_creds.get('client_secret') - print(client_secret) except Exception as e: print(f"Error loading search credentials: {e}") diff --git a/routes/utils/playlist.py b/routes/utils/playlist.py index 5f0abab..e008489 100755 --- a/routes/utils/playlist.py +++ b/routes/utils/playlist.py @@ -14,7 +14,11 @@ def download_playlist( fall_quality=None, real_time=False, custom_dir_format="%ar_album%/%album%/%copyright%", - custom_track_format="%tracknum%. %music% - %artist%" + custom_track_format="%tracknum%. %music% - %artist%", + pad_tracks=True, + initial_retry_delay=5, + retry_delay_increase=5, + max_retries=3 ): try: # Load Spotify client credentials if available @@ -60,7 +64,11 @@ def download_playlist( make_zip=False, method_save=1, custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries ) except Exception as e: # Load fallback Spotify credentials and attempt download @@ -97,7 +105,11 @@ def download_playlist( make_zip=False, real_time_dl=real_time, custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries ) except Exception as e2: # If fallback also fails, raise an error indicating both attempts failed @@ -127,7 +139,11 @@ def download_playlist( make_zip=False, real_time_dl=real_time, custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries ) elif service == 'deezer': if quality is None: @@ -151,7 +167,11 @@ def download_playlist( method_save=1, make_zip=False, custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries ) else: raise ValueError(f"Unsupported service: {service}") diff --git a/routes/utils/queue.py b/routes/utils/queue.py index 8491b8a..1a8a0eb 100644 --- a/routes/utils/queue.py +++ b/routes/utils/queue.py @@ -6,7 +6,9 @@ import string import random import traceback import threading -from multiprocessing import Process +import signal +import atexit +from multiprocessing import Process, Event from queue import Queue, Empty # ------------------------------------------------------------------------------ @@ -19,9 +21,19 @@ try: with open(CONFIG_PATH, 'r') as f: config_data = json.load(f) MAX_CONCURRENT_DL = config_data.get("maxConcurrentDownloads", 3) + MAX_RETRIES = config_data.get("maxRetries", 3) + RETRY_DELAY = config_data.get("retryDelaySeconds", 5) + RETRY_DELAY_INCREASE = config_data.get("retry_delay_increase", 5) + # Hardcode the queue state file to be in the config/state directory + QUEUE_STATE_FILE = "./config/state/queue_state.json" except Exception as e: - # Fallback to a default value if there's an error reading the config. + print(f"Error loading configuration: {e}") + # Fallback to default values if there's an error reading the config. MAX_CONCURRENT_DL = 3 + MAX_RETRIES = 3 + RETRY_DELAY = 5 + RETRY_DELAY_INCREASE = 5 + QUEUE_STATE_FILE = "./config/state/queue_state.json" PRG_DIR = './prgs' # directory where .prg files will be stored @@ -29,6 +41,50 @@ PRG_DIR = './prgs' # directory where .prg files will be stored # Utility Functions and Classes # ------------------------------------------------------------------------------ +def get_config_params(): + """ + Get common download parameters from the config file. + This centralizes parameter retrieval and reduces redundancy in API calls. + + Returns: + dict: A dictionary containing common parameters from config + """ + try: + with open(CONFIG_PATH, 'r') as f: + config = json.load(f) + + return { + 'spotify': config.get('spotify', ''), + 'deezer': config.get('deezer', ''), + 'fallback': config.get('fallback', False), + 'spotifyQuality': config.get('spotifyQuality', 'NORMAL'), + 'deezerQuality': config.get('deezerQuality', 'MP3_128'), + 'realTime': config.get('realTime', False), + 'customDirFormat': config.get('customDirFormat', '%ar_album%/%album%'), + 'customTrackFormat': config.get('customTrackFormat', '%tracknum%. %music%'), + 'tracknum_padding': config.get('tracknum_padding', True), + 'maxRetries': config.get('maxRetries', 3), + 'retryDelaySeconds': config.get('retryDelaySeconds', 5), + 'retry_delay_increase': config.get('retry_delay_increase', 5) + } + except Exception as e: + print(f"Error reading config for parameters: {e}") + # Return defaults if config read fails + return { + 'spotify': '', + 'deezer': '', + 'fallback': False, + 'spotifyQuality': 'NORMAL', + 'deezerQuality': 'MP3_128', + 'realTime': False, + 'customDirFormat': '%ar_album%/%album%', + 'customTrackFormat': '%tracknum%. %music%', + 'tracknum_padding': True, + 'maxRetries': 3, + 'retryDelaySeconds': 5, + 'retry_delay_increase': 5 + } + def generate_random_filename(length=6, extension=".prg"): """Generate a random filename with the given extension.""" chars = string.ascii_lowercase + string.digits @@ -52,71 +108,252 @@ class FlushingFileWrapper: continue # skip lines that represent track messages except ValueError: pass # not valid JSON; write the line as is - self.file.write(line + '\n') - self.file.flush() + if line: # Only write non-empty lines + try: + self.file.write(line + '\n') + self.file.flush() + except (IOError, OSError) as e: + print(f"Error writing to file: {e}") def flush(self): - self.file.flush() + try: + self.file.flush() + except (IOError, OSError) as e: + print(f"Error flushing file: {e}") + + def close(self): + """ + Close the underlying file object. + """ + try: + self.file.flush() + self.file.close() + except (IOError, OSError) as e: + print(f"Error closing file: {e}") -def run_download_task(task, prg_path): +def handle_termination(signum, frame): """ - This function is executed in a separate process. - It opens the given prg file (in append mode), calls the appropriate download - function (album, track, or playlist), and writes a completion or error status - to the file. + Signal handler for graceful termination of download processes. + Called when a SIGTERM signal is received. + + Args: + signum: The signal number + frame: The current stack frame """ try: - # Determine which download function to use based on task type. - download_type = task.get("download_type") - if download_type == "album": - from routes.utils.album import download_album - download_func = download_album - elif download_type == "track": + print(f"Process received termination signal {signum}") + sys.exit(0) + except Exception as e: + print(f"Error during termination: {e}") + sys.exit(1) + +class StdoutRedirector: + """ + Class that redirects stdout/stderr to a file. + All print statements will be captured and written directly to the target file. + """ + def __init__(self, file_wrapper): + self.file_wrapper = file_wrapper + + def write(self, message): + if message and not message.isspace(): + # Pass the message directly without wrapping it in JSON + self.file_wrapper.write(message.rstrip()) + + def flush(self): + self.file_wrapper.flush() + +def run_download_task(task, prg_path, stop_event=None): + """ + Process a download task based on its type (album, track, playlist, artist). + This function is run in a separate process. + + Args: + task (dict): The task details + prg_path (str): Path to the .prg file for progress updates + stop_event (threading.Event, optional): Used to signal the process to stop gracefully. + """ + # Register signal handler for graceful termination + signal.signal(signal.SIGTERM, handle_termination) + + # Extract common parameters from the task + download_type = task.get("download_type", "unknown") + service = task.get("service", "") + url = task.get("url", "") + main = task.get("main", "") + fallback = task.get("fallback", None) + quality = task.get("quality", None) + fall_quality = task.get("fall_quality", None) + real_time = task.get("real_time", False) + custom_dir_format = task.get("custom_dir_format", "%ar_album%/%album%/%copyright%") + custom_track_format = task.get("custom_track_format", "%tracknum%. %music% - %artist%") + pad_tracks = task.get("pad_tracks", True) + + # Extract retry configuration parameters from the task or use defaults + max_retries = task.get("max_retries", MAX_RETRIES) + initial_retry_delay = task.get("initial_retry_delay", RETRY_DELAY) + retry_delay_increase = task.get("retry_delay_increase", RETRY_DELAY_INCREASE) + + # Get the current retry count (or 0 if not set) + retry_count = task.get("retry_count", 0) + + # Calculate current retry delay based on the retry count + current_retry_delay = initial_retry_delay + (retry_count * retry_delay_increase) + + # Initialize variables for cleanup in finally block + wrapper = None + original_stdout = sys.stdout + original_stderr = sys.stderr + + try: + # Initialize a FlushingFileWrapper for real-time progress updates + try: + prg_file = open(prg_path, 'a') + wrapper = FlushingFileWrapper(prg_file) + except Exception as e: + print(f"Error opening PRG file {prg_path}: {e}") + return + + # If this is a retry, log the retry and delay + if retry_count > 0: + wrapper.write(json.dumps({ + "status": "retrying", + "retry_count": retry_count, + "max_retries": max_retries, + "retry_delay": current_retry_delay, + "timestamp": time.time(), + "message": f"Retry attempt {retry_count}/{max_retries} after {current_retry_delay}s delay" + }) + "\n") + + # Sleep for the calculated delay before attempting retry + time.sleep(current_retry_delay) + + # Redirect stdout and stderr to the progress file + stdout_redirector = StdoutRedirector(wrapper) + sys.stdout = stdout_redirector + sys.stderr = stdout_redirector + + # Check for early termination + if stop_event and stop_event.is_set(): + wrapper.write(json.dumps({ + "status": "interrupted", + "message": "Task was interrupted before starting the download", + "timestamp": time.time() + }) + "\n") + return + + # Dispatch to the appropriate download function based on download_type + if download_type == "track": from routes.utils.track import download_track - download_func = download_track + download_track( + service=service, + url=url, + main=main, + fallback=fallback, + quality=quality, + fall_quality=fall_quality, + real_time=real_time, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries + ) + elif download_type == "album": + from routes.utils.album import download_album + download_album( + service=service, + url=url, + main=main, + fallback=fallback, + quality=quality, + fall_quality=fall_quality, + real_time=real_time, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries + ) elif download_type == "playlist": from routes.utils.playlist import download_playlist - download_func = download_playlist + download_playlist( + service=service, + url=url, + main=main, + fallback=fallback, + quality=quality, + fall_quality=fall_quality, + real_time=real_time, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries + ) else: - raise ValueError(f"Unsupported download type: {download_type}") - - # Open the .prg file in append mode so as not to overwrite the queued lines. - with open(prg_path, 'a') as f: - flushing_file = FlushingFileWrapper(f) - original_stdout = sys.stdout - sys.stdout = flushing_file - - try: - # Call the appropriate download function with parameters from the task. - download_func( - service=task.get("service"), - url=task.get("url"), - main=task.get("main"), - fallback=task.get("fallback"), - quality=task.get("quality"), - fall_quality=task.get("fall_quality"), - real_time=task.get("real_time", False), - custom_dir_format=task.get("custom_dir_format", "%ar_album%/%album%/%copyright%"), - custom_track_format=task.get("custom_track_format", "%tracknum%. %music% - %artist%") - ) - flushing_file.write(json.dumps({"status": "complete"}) + "\n") - except Exception as e: - flushing_file.write(json.dumps({ - "status": "error", - "message": str(e), - "traceback": traceback.format_exc() - }) + "\n") - finally: - sys.stdout = original_stdout # restore original stdout - except Exception as e: - # If something fails even before opening the prg file properly. - with open(prg_path, 'a') as f: - error_data = json.dumps({ + wrapper.write(json.dumps({ "status": "error", - "message": str(e), - "traceback": traceback.format_exc() - }) - f.write(error_data + "\n") + "message": f"Unsupported download type: {download_type}", + "can_retry": False, + "timestamp": time.time() + }) + "\n") + return + + # If we got here, the download completed successfully + wrapper.write(json.dumps({ + "status": "complete", + "message": f"Download completed successfully.", + "timestamp": time.time() + }) + "\n") + + except Exception as e: + if wrapper: + traceback.print_exc() + + # Check if we can retry the task + can_retry = retry_count < max_retries + + # Log the error and if it can be retried + try: + wrapper.write(json.dumps({ + "status": "error", + "error": str(e), + "traceback": traceback.format_exc(), + "can_retry": can_retry, + "retry_count": retry_count, + "max_retries": max_retries, + "retry_delay": current_retry_delay + retry_delay_increase if can_retry else None, + "timestamp": time.time(), + "message": f"Error: {str(e)}" + }) + "\n") + except Exception as inner_error: + print(f"Error writing error status to PRG file: {inner_error}") + else: + print(f"Error in download task (wrapper not available): {e}") + traceback.print_exc() + finally: + # Restore original stdout and stderr + sys.stdout = original_stdout + sys.stderr = original_stderr + + # Safely clean up wrapper and file + if wrapper: + try: + wrapper.flush() + wrapper.close() + except Exception as e: + print(f"Error closing wrapper: {e}") + + # Try to close the underlying file directly if wrapper close fails + try: + if hasattr(wrapper, 'file') and wrapper.file and not wrapper.file.closed: + wrapper.file.close() + except Exception as file_error: + print(f"Error directly closing file: {file_error}") # ------------------------------------------------------------------------------ # Download Queue Manager Class @@ -133,50 +370,142 @@ class DownloadQueueManager: os.makedirs(self.prg_dir, exist_ok=True) self.pending_tasks = Queue() # holds tasks waiting to run - self.running_downloads = {} # maps prg_filename -> Process instance + self.running_downloads = {} # maps prg_filename -> (Process instance, task data, stop_event) self.cancelled_tasks = set() # holds prg_filenames of tasks that have been cancelled - self.lock = threading.Lock() # protects access to running_downloads and cancelled_tasks + self.failed_tasks = {} # maps prg_filename -> (task data, failure count) + self.lock = threading.Lock() # protects access to shared data structures self.worker_thread = threading.Thread(target=self.queue_worker, daemon=True) self.running = False + self.paused = False + + # Print manager configuration for debugging + print(f"Download Queue Manager initialized with max_concurrent={self.max_concurrent}, using prg_dir={self.prg_dir}") + + # Load persisted queue state if available + self.load_queue_state() + + # Register cleanup on application exit + atexit.register(self.cleanup) def start(self): """Start the worker thread that monitors the queue.""" self.running = True self.worker_thread.start() + print("Download queue manager started") + + def pause(self): + """Pause processing of new tasks.""" + self.paused = True + print("Download queue processing paused") + + def resume(self): + """Resume processing of tasks.""" + self.paused = False + print("Download queue processing resumed") def stop(self): """Stop the worker thread gracefully.""" + print("Stopping download queue manager...") self.running = False - self.worker_thread.join() + self.save_queue_state() + + # Wait for the worker thread to finish + if self.worker_thread.is_alive(): + self.worker_thread.join(timeout=5) + + # Clean up any running processes + self.terminate_all_downloads() + print("Download queue manager stopped") + + def cleanup(self): + """Clean up resources when the application exits.""" + if self.running: + self.stop() + + def save_queue_state(self): + """Save the current queue state to a file for persistence.""" + try: + # Build a serializable state object + with self.lock: + # Get current pending tasks (without removing them) + pending_tasks = [] + with self.pending_tasks.mutex: + for item in list(self.pending_tasks.queue): + prg_filename, task = item + pending_tasks.append({"prg_filename": prg_filename, "task": task}) + + # Get failed tasks + failed_tasks = {} + for prg_filename, (task, retry_count) in self.failed_tasks.items(): + failed_tasks[prg_filename] = {"task": task, "retry_count": retry_count} + + state = { + "pending_tasks": pending_tasks, + "failed_tasks": failed_tasks, + "cancelled_tasks": list(self.cancelled_tasks) + } + + # Write state to file + with open(QUEUE_STATE_FILE, 'w') as f: + json.dump(state, f) + except Exception as e: + print(f"Error saving queue state: {e}") + + def load_queue_state(self): + """Load queue state from a persistent file if available.""" + try: + if os.path.exists(QUEUE_STATE_FILE): + with open(QUEUE_STATE_FILE, 'r') as f: + state = json.load(f) + + # Restore state + with self.lock: + # Restore pending tasks + for task_info in state.get("pending_tasks", []): + self.pending_tasks.put((task_info["prg_filename"], task_info["task"])) + + # Restore failed tasks + for prg_filename, task_info in state.get("failed_tasks", {}).items(): + self.failed_tasks[prg_filename] = (task_info["task"], task_info["retry_count"]) + + # Restore cancelled tasks + self.cancelled_tasks = set(state.get("cancelled_tasks", [])) + + print(f"Loaded queue state: {len(state.get('pending_tasks', []))} pending tasks, {len(state.get('failed_tasks', {}))} failed tasks") + except Exception as e: + print(f"Error loading queue state: {e}") def add_task(self, task): """ Adds a new download task to the queue. The task is expected to be a dictionary with all necessary parameters, - including a "download_type" key (album, track, or playlist). + including a "download_type" key (album, track, playlist, or artist). - A .prg file is created for progress logging with an initial two entries: + A .prg file is created for progress logging with an initial entries: 1. The original request (merged with the extra keys: type, name, artist) 2. A queued status entry (including type, name, artist, and the task's position in the queue) - The position in the queue is determined by scanning the PRG directory for existing files - that follow the naming scheme (download_type_.prg). The new task gets the next available number. - Returns the generated prg filename so that the caller can later check the status or request cancellation. """ download_type = task.get("download_type", "unknown") # Determine the new task's position by scanning the PRG_DIR for files matching the naming scheme. existing_positions = [] - for filename in os.listdir(self.prg_dir): - if filename.startswith(f"{download_type}_") and filename.endswith(".prg"): - try: - # Filename format: download_type_.prg - number_part = filename[len(download_type) + 1:-4] - pos_num = int(number_part) - existing_positions.append(pos_num) - except ValueError: - continue # Skip files that do not conform to the naming scheme. + try: + for filename in os.listdir(self.prg_dir): + if filename.startswith(f"{download_type}_") and filename.endswith(".prg"): + try: + # Filename format: download_type_.prg + number_part = filename[len(download_type) + 1:-4] + pos_num = int(number_part) + existing_positions.append(pos_num) + except ValueError: + continue # Skip files that do not conform to the naming scheme. + except Exception as e: + print(f"Error scanning directory: {e}") + # If we can't scan the directory, generate a random filename instead + return self._add_task_with_random_filename(task) + position = max(existing_positions, default=0) + 1 # Generate the prg filename based on the download type and determined position. @@ -184,62 +513,586 @@ class DownloadQueueManager: prg_path = os.path.join(self.prg_dir, prg_filename) task['prg_path'] = prg_path + # Initialize retry count and add retry parameters + task['retry_count'] = 0 + + # Get retry configuration from config, or use what's provided in the task + config_params = get_config_params() + task['max_retries'] = task.get('max_retries', config_params.get('maxRetries', MAX_RETRIES)) + task['initial_retry_delay'] = task.get('initial_retry_delay', config_params.get('retryDelaySeconds', RETRY_DELAY)) + task['retry_delay_increase'] = task.get('retry_delay_increase', config_params.get('retry_delay_increase', RETRY_DELAY_INCREASE)) + # Create and immediately write the initial entries to the .prg file. try: with open(prg_path, 'w') as f: # Merge extra keys into the original request. - original_request = task.get("orig_request", {}) - for key in ["type", "name", "artist"]: - if key in task and task[key] is not None: - original_request[key] = task[key] - f.write(json.dumps({"original_request": original_request}) + "\n") + original_request = task.get("orig_request", {}).copy() - # Write a queued status entry with the extra parameters and queue position. - queued_entry = { + # Add essential metadata for retry operations + original_request["download_type"] = download_type + + # Ensure key information is included + for key in ["type", "name", "artist", "service", "url"]: + if key in task and key not in original_request: + original_request[key] = task[key] + + # Add API endpoint information + if "endpoint" not in original_request: + original_request["endpoint"] = f"/api/{download_type}/download" + + # Add explicit display information for the frontend + original_request["display_title"] = task.get("name", original_request.get("name", "Unknown")) + original_request["display_type"] = task.get("type", original_request.get("type", download_type)) + original_request["display_artist"] = task.get("artist", original_request.get("artist", "")) + + # Write the first entry - the enhanced original request params + f.write(json.dumps(original_request) + "\n") + + # Write the second entry - the queued status + f.write(json.dumps({ "status": "queued", - "name": task.get("name"), - "type": task.get("type"), - "artist": task.get("artist"), - "position": position - } - f.write(json.dumps(queued_entry) + "\n") + "timestamp": time.time(), + "type": task.get("type", ""), + "name": task.get("name", ""), + "artist": task.get("artist", ""), + "retry_count": 0, + "max_retries": task.get('max_retries', MAX_RETRIES), + "initial_retry_delay": task.get('initial_retry_delay', RETRY_DELAY), + "retry_delay_increase": task.get('retry_delay_increase', RETRY_DELAY_INCREASE), + "queue_position": self.pending_tasks.qsize() + 1 + }) + "\n") except Exception as e: - print("Error writing prg file:", e) + print(f"Error writing to PRG file: {e}") + # If we can't create the file, try with a random filename + return self._add_task_with_random_filename(task) + # Add the task to the pending queue self.pending_tasks.put((prg_filename, task)) + self.save_queue_state() + + print(f"Added task {prg_filename} to download queue") return prg_filename + def _add_task_with_random_filename(self, task): + """ + Helper method to create a task with a random filename + in case we can't generate a sequential filename. + """ + try: + download_type = task.get("download_type", "unknown") + random_id = generate_random_filename(extension="") + prg_filename = f"{download_type}_{random_id}.prg" + prg_path = os.path.join(self.prg_dir, prg_filename) + task['prg_path'] = prg_path + + # Initialize retry count and add retry parameters + task['retry_count'] = 0 + + # Get retry configuration from config, or use what's provided in the task + config_params = get_config_params() + task['max_retries'] = task.get('max_retries', config_params.get('maxRetries', MAX_RETRIES)) + task['initial_retry_delay'] = task.get('initial_retry_delay', config_params.get('retryDelaySeconds', RETRY_DELAY)) + task['retry_delay_increase'] = task.get('retry_delay_increase', config_params.get('retry_delay_increase', RETRY_DELAY_INCREASE)) + + with open(prg_path, 'w') as f: + # Merge extra keys into the original request + original_request = task.get("orig_request", {}).copy() + + # Add essential metadata for retry operations + original_request["download_type"] = download_type + + # Ensure key information is included + for key in ["type", "name", "artist", "service", "url"]: + if key in task and key not in original_request: + original_request[key] = task[key] + + # Add API endpoint information + if "endpoint" not in original_request: + original_request["endpoint"] = f"/api/{download_type}/download" + + # Add explicit display information for the frontend + original_request["display_title"] = task.get("name", original_request.get("name", "Unknown")) + original_request["display_type"] = task.get("type", original_request.get("type", download_type)) + original_request["display_artist"] = task.get("artist", original_request.get("artist", "")) + + # Write the first entry - the enhanced original request params + f.write(json.dumps(original_request) + "\n") + + # Write the second entry - the queued status + f.write(json.dumps({ + "status": "queued", + "timestamp": time.time(), + "type": task.get("type", ""), + "name": task.get("name", ""), + "artist": task.get("artist", ""), + "retry_count": 0, + "max_retries": task.get('max_retries', MAX_RETRIES), + "initial_retry_delay": task.get('initial_retry_delay', RETRY_DELAY), + "retry_delay_increase": task.get('retry_delay_increase', RETRY_DELAY_INCREASE), + "queue_position": self.pending_tasks.qsize() + 1 + }) + "\n") + + self.pending_tasks.put((prg_filename, task)) + self.save_queue_state() + + print(f"Added task {prg_filename} to download queue (with random filename)") + return prg_filename + except Exception as e: + print(f"Error adding task with random filename: {e}") + return None + + def retry_task(self, prg_filename): + """ + Retry a failed task by creating a new PRG file and adding it back to the queue. + """ + with self.lock: + # Check if the task is in failed_tasks + if prg_filename not in self.failed_tasks: + return { + "status": "error", + "message": f"Task {prg_filename} not found in failed tasks" + } + + task, retry_count = self.failed_tasks.pop(prg_filename) + # Increment the retry count + task["retry_count"] = retry_count + 1 + + # Get retry configuration parameters from config, not from the task + config_params = get_config_params() + max_retries = config_params.get('maxRetries', MAX_RETRIES) + initial_retry_delay = config_params.get('retryDelaySeconds', RETRY_DELAY) + retry_delay_increase = config_params.get('retry_delay_increase', RETRY_DELAY_INCREASE) + + # Update task with the latest config values + task["max_retries"] = max_retries + task["initial_retry_delay"] = initial_retry_delay + task["retry_delay_increase"] = retry_delay_increase + + # Calculate the new retry delay + current_retry_delay = initial_retry_delay + (task["retry_count"] * retry_delay_increase) + + # If we've exceeded the maximum retries, return an error + if task["retry_count"] > max_retries: + return { + "status": "error", + "message": f"Maximum retry attempts ({max_retries}) exceeded" + } + + # Use the same download type as the original task. + download_type = task.get("download_type", "unknown") + + # Generate a new task with a new PRG filename for the retry. + # We're using the original file name with a retry count suffix. + original_name = os.path.splitext(prg_filename)[0] + new_prg_filename = f"{original_name}_retry{task['retry_count']}.prg" + new_prg_path = os.path.join(self.prg_dir, new_prg_filename) + task["prg_path"] = new_prg_path + + # Try to load the original request information from the original PRG file + original_request = {} + original_prg_path = os.path.join(self.prg_dir, prg_filename) + try: + if os.path.exists(original_prg_path): + with open(original_prg_path, 'r') as f: + first_line = f.readline().strip() + if first_line: + try: + original_request = json.loads(first_line) + except json.JSONDecodeError: + pass + except Exception as e: + print(f"Error reading original request from {prg_filename}: {e}") + + # If we couldn't get the original request, use what we have in the task + if not original_request: + original_request = task.get("orig_request", {}).copy() + # Add essential metadata for retry operations + original_request["download_type"] = download_type + for key in ["type", "name", "artist", "service", "url"]: + if key in task and key not in original_request: + original_request[key] = task[key] + # Add API endpoint information + if "endpoint" not in original_request: + original_request["endpoint"] = f"/api/{download_type}/download" + + # Add explicit display information for the frontend + original_request["display_title"] = task.get("name", "Unknown") + original_request["display_type"] = task.get("type", download_type) + original_request["display_artist"] = task.get("artist", "") + elif not any(key in original_request for key in ["display_title", "display_type", "display_artist"]): + # Ensure display fields exist if they weren't in the original request + original_request["display_title"] = original_request.get("name", task.get("name", "Unknown")) + original_request["display_type"] = original_request.get("type", task.get("type", download_type)) + original_request["display_artist"] = original_request.get("artist", task.get("artist", "")) + + # Create and immediately write the retry information to the new PRG file. + try: + with open(new_prg_path, 'w') as f: + # First, write the original request information + f.write(json.dumps(original_request) + "\n") + + # Then write the queued status with retry information + f.write(json.dumps({ + "status": "queued", + "type": task.get("type", "unknown"), + "name": task.get("name", "Unknown"), + "artist": task.get("artist", "Unknown"), + "retry_count": task["retry_count"], + "max_retries": max_retries, + "retry_delay": current_retry_delay, + "timestamp": time.time() + }) + "\n") + except Exception as e: + print(f"Error creating retry PRG file: {e}") + return { + "status": "error", + "message": f"Failed to create retry file: {str(e)}" + } + + # Add the task to the pending_tasks queue. + self.pending_tasks.put((new_prg_filename, task)) + print(f"Requeued task {new_prg_filename} for retry (attempt {task['retry_count']})") + + # Save updated queue state + self.save_queue_state() + + return { + "status": "requeued", + "prg_file": new_prg_filename, + "retry_count": task["retry_count"], + "max_retries": max_retries, + "retry_delay": current_retry_delay, + } + def cancel_task(self, prg_filename): """ - Cancel a download task (either queued or running) by marking it as cancelled or terminating its process. - If the task is running, its process is terminated. - If the task is queued, it is marked as cancelled so that it won't be started. - In either case, a cancellation status is appended to its .prg file. - - Returns a dictionary indicating the result. + Cancels a running or queued download task by its PRG filename. + Returns a status dictionary that should be returned to the client. """ prg_path = os.path.join(self.prg_dir, prg_filename) + + # First, check if the task is even valid (file exists) + if not os.path.exists(prg_path): + return {"status": "error", "message": f"Task {prg_filename} not found"} + with self.lock: - process = self.running_downloads.get(prg_filename) - if process and process.is_alive(): - process.terminate() - process.join() + # Check if task is currently running + if prg_filename in self.running_downloads: + # Get the process and stop event + process, task, stop_event = self.running_downloads[prg_filename] + + # Signal the process to stop gracefully using the event + stop_event.set() + + # Give the process a short time to terminate gracefully + process.join(timeout=2) + + # If the process is still alive, terminate it forcefully + if process.is_alive(): + print(f"Terminating process for {prg_filename} forcefully") + process.terminate() + process.join(timeout=1) + + # If still alive after terminate, kill it + if process.is_alive(): + print(f"Process for {prg_filename} not responding to terminate, killing") + try: + if hasattr(process, 'kill'): + process.kill() + else: + os.kill(process.pid, signal.SIGKILL) + except: + print(f"Error killing process for {prg_filename}") + + # Clean up by removing from running downloads del self.running_downloads[prg_filename] + + # Update the PRG file to indicate cancellation try: with open(prg_path, 'a') as f: - f.write(json.dumps({"status": "cancel"}) + "\n") + f.write(json.dumps({ + "status": "cancel", + "timestamp": time.time() + }) + "\n") except Exception as e: - return {"error": f"Failed to write cancel status: {str(e)}"} - return {"status": "cancelled"} - else: - # Task is not running; mark it as cancelled if it's still pending. - self.cancelled_tasks.add(prg_filename) + print(f"Error writing cancel status: {e}") + + print(f"Cancelled running task: {prg_filename}") + return {"status": "cancelled", "prg_file": prg_filename} + + # If not running, check if it's a planned retry + if prg_filename in self.failed_tasks: + del self.failed_tasks[prg_filename] + + # Update the PRG file to indicate cancellation try: with open(prg_path, 'a') as f: - f.write(json.dumps({"status": "cancel"}) + "\n") + f.write(json.dumps({ + "status": "cancel", + "timestamp": time.time() + }) + "\n") except Exception as e: - return {"error": f"Failed to write cancel status: {str(e)}"} - return {"status": "cancelled"} + print(f"Error writing cancel status: {e}") + + print(f"Cancelled retry task: {prg_filename}") + return {"status": "cancelled", "prg_file": prg_filename} + + # If not running, it might be queued; mark as cancelled + self.cancelled_tasks.add(prg_filename) + + # If it's in the queue, try to update its status in the PRG file + try: + with open(prg_path, 'a') as f: + f.write(json.dumps({ + "status": "cancel", + "timestamp": time.time() + }) + "\n") + except Exception as e: + print(f"Error writing cancel status: {e}") + + print(f"Marked queued task as cancelled: {prg_filename}") + return {"status": "cancelled", "prg_file": prg_filename} + + def cancel_all_tasks(self): + """Cancel all currently queued and running tasks.""" + with self.lock: + # First, mark all pending tasks as cancelled + with self.pending_tasks.mutex: + for item in list(self.pending_tasks.queue): + prg_filename, _ = item + self.cancelled_tasks.add(prg_filename) + prg_path = os.path.join(self.prg_dir, prg_filename) + try: + with open(prg_path, 'a') as f: + f.write(json.dumps({ + "status": "cancel", + "message": "Task was cancelled by user", + "timestamp": time.time() + }) + "\n") + except Exception as e: + print(f"Error writing cancelled status for {prg_filename}: {e}") + # Clear the queue + self.pending_tasks.queue.clear() + + # Next, terminate all running tasks + for prg_filename, (process, _, stop_event) in list(self.running_downloads.items()): + if stop_event: + stop_event.set() + + if process and process.is_alive(): + try: + process.terminate() + prg_path = os.path.join(self.prg_dir, prg_filename) + with open(prg_path, 'a') as f: + f.write(json.dumps({ + "status": "cancel", + "message": "Task was cancelled by user", + "timestamp": time.time() + }) + "\n") + except Exception as e: + print(f"Error cancelling task {prg_filename}: {e}") + + # Clear all running downloads + self.running_downloads.clear() + + # Clear failed tasks + self.failed_tasks.clear() + + self.save_queue_state() + return {"status": "all_cancelled"} + + def terminate_all_downloads(self): + """Terminate all running download processes.""" + with self.lock: + for prg_filename, (process, _, stop_event) in list(self.running_downloads.items()): + if stop_event: + stop_event.set() + + if process and process.is_alive(): + try: + process.terminate() + process.join(timeout=2) + if process.is_alive(): + print(f"Process for {prg_filename} did not terminate, forcing kill") + process.kill() + process.join(timeout=1) + except Exception as e: + print(f"Error terminating process: {e}") + + self.running_downloads.clear() + + def get_queue_status(self): + """Get the current status of the queue.""" + with self.lock: + running_count = len(self.running_downloads) + pending_count = self.pending_tasks.qsize() + failed_count = len(self.failed_tasks) + + # Get info about current running tasks + running_tasks = [] + for prg_filename, (_, task, _) in self.running_downloads.items(): + running_tasks.append({ + "prg_filename": prg_filename, + "name": task.get("name", "Unknown"), + "type": task.get("type", "unknown"), + "download_type": task.get("download_type", "unknown") + }) + + # Get info about failed tasks + failed_tasks = [] + for prg_filename, (task, retry_count) in self.failed_tasks.items(): + failed_tasks.append({ + "prg_filename": prg_filename, + "name": task.get("name", "Unknown"), + "type": task.get("type", "unknown"), + "download_type": task.get("download_type", "unknown"), + "retry_count": retry_count + }) + + return { + "running": running_count, + "pending": pending_count, + "failed": failed_count, + "max_concurrent": self.max_concurrent, + "paused": self.paused, + "running_tasks": running_tasks, + "failed_tasks": failed_tasks + } + + def check_for_stuck_tasks(self): + """ + Scan for tasks that appear to be stuck and requeue them if necessary. + Called periodically by the queue worker. + """ + print("Checking for stuck tasks...") + + # First, scan the running tasks to see if any processes are defunct + with self.lock: + defunct_tasks = [] + stalled_tasks = [] + current_time = time.time() + + for prg_filename, (process, task, stop_event) in list(self.running_downloads.items()): + if not process.is_alive(): + # Process is no longer alive but wasn't cleaned up + defunct_tasks.append((prg_filename, task)) + print(f"Found defunct task {prg_filename}, process is no longer alive") + + # Check task prg file timestamp to detect stalled tasks + prg_path = os.path.join(self.prg_dir, prg_filename) + try: + last_modified = os.path.getmtime(prg_path) + if current_time - last_modified > 300: # 5 minutes + print(f"Task {prg_filename} may be stalled, last activity: {current_time - last_modified:.1f} seconds ago") + # Add to stalled tasks list for potential termination + stalled_tasks.append((prg_filename, process, task, stop_event)) + except Exception as e: + print(f"Error checking task timestamp: {e}") + + # Clean up defunct tasks + for prg_filename, task in defunct_tasks: + print(f"Cleaning up defunct task: {prg_filename}") + del self.running_downloads[prg_filename] + + # If task still has retries left, requeue it + retry_count = task.get("retry_count", 0) + if retry_count < MAX_RETRIES: + task["retry_count"] = retry_count + 1 + print(f"Requeuing task {prg_filename}, retry count: {task['retry_count']}") + + # Update the PRG file to indicate the task is being requeued + prg_path = os.path.join(self.prg_dir, prg_filename) + try: + with open(prg_path, 'a') as f: + f.write(json.dumps({ + "status": "requeued", + "message": "Task was automatically requeued after process died", + "retry_count": task["retry_count"], + "timestamp": time.time() + }) + "\n") + except Exception as e: + print(f"Error writing to PRG file for requeued task: {e}") + + self.pending_tasks.put((prg_filename, task)) + else: + # No more retries - mark as failed + try: + with open(prg_path, 'a') as f: + f.write(json.dumps({ + "status": "error", + "message": "Task failed - maximum retry count reached", + "can_retry": False, + "timestamp": time.time() + }) + "\n") + except Exception as e: + print(f"Error writing to PRG file for failed task: {e}") + + # Handle stalled tasks + for prg_filename, process, task, stop_event in stalled_tasks: + print(f"Terminating stalled task {prg_filename}") + + # Signal the process to stop gracefully + if stop_event: + stop_event.set() + + # Give it a short time to terminate gracefully + process.join(timeout=2) + + # If still alive, terminate forcefully + if process.is_alive(): + process.terminate() + process.join(timeout=1) + + # If still alive after terminate, kill it + if process.is_alive(): + try: + if hasattr(process, 'kill'): + process.kill() + else: + os.kill(process.pid, signal.SIGKILL) + except Exception as e: + print(f"Error killing process for {prg_filename}: {e}") + + # Remove from running downloads + del self.running_downloads[prg_filename] + + # If task still has retries left, requeue it + retry_count = task.get("retry_count", 0) + if retry_count < MAX_RETRIES: + task["retry_count"] = retry_count + 1 + print(f"Requeuing stalled task {prg_filename}, retry count: {task['retry_count']}") + + # Update the PRG file to indicate the task is being requeued + prg_path = os.path.join(self.prg_dir, prg_filename) + try: + with open(prg_path, 'a') as f: + f.write(json.dumps({ + "status": "requeued", + "message": "Task was automatically requeued after stalling", + "retry_count": task["retry_count"], + "timestamp": time.time() + }) + "\n") + except Exception as e: + print(f"Error writing to PRG file for requeued task: {e}") + + self.pending_tasks.put((prg_filename, task)) + else: + # No more retries - mark as failed + prg_path = os.path.join(self.prg_dir, prg_filename) + try: + with open(prg_path, 'a') as f: + f.write(json.dumps({ + "status": "error", + "message": "Task stalled - maximum retry count reached", + "can_retry": False, + "timestamp": time.time() + }) + "\n") + except Exception as e: + print(f"Error writing to PRG file for failed task: {e}") + + # Save queue state after processing stuck tasks + if defunct_tasks or stalled_tasks: + self.save_queue_state() def queue_worker(self): """ @@ -247,46 +1100,109 @@ class DownloadQueueManager: It cleans up finished download processes and starts new ones if the number of running downloads is less than the allowed maximum. """ + last_stuck_check = time.time() + while self.running: - # First, clean up any finished processes. - with self.lock: - finished = [] - for prg_filename, process in list(self.running_downloads.items()): - if not process.is_alive(): - finished.append(prg_filename) - for prg_filename in finished: - del self.running_downloads[prg_filename] - - # Start new tasks if there is available capacity. - if len(self.running_downloads) < self.max_concurrent: - try: - prg_filename, task = self.pending_tasks.get(timeout=1) - except Empty: - time.sleep(0.5) - continue - - # Check if the task was cancelled while it was still queued. + try: + # Periodically check for stuck tasks + current_time = time.time() + if current_time - last_stuck_check > 60: # Check every minute + self.check_for_stuck_tasks() + last_stuck_check = current_time + + # First, clean up any finished processes. with self.lock: - if prg_filename in self.cancelled_tasks: - # Task has been cancelled; remove it from the set and skip processing. - self.cancelled_tasks.remove(prg_filename) + finished = [] + for prg_filename, (process, task, _) in list(self.running_downloads.items()): + if not process.is_alive(): + finished.append((prg_filename, task)) + + for prg_filename, task in finished: + del self.running_downloads[prg_filename] + + # Check if the task completed successfully or failed + prg_path = os.path.join(self.prg_dir, prg_filename) + try: + # Read the last line of the prg file to check status + with open(prg_path, 'r') as f: + lines = f.readlines() + if lines: + last_line = lines[-1].strip() + try: + status = json.loads(last_line) + # Check if the task failed and can be retried + if status.get("status") == "error" and status.get("can_retry", False): + retry_count = task.get("retry_count", 0) + if retry_count < MAX_RETRIES: + # Add to failed tasks for potential retry + self.failed_tasks[prg_filename] = (task, retry_count) + print(f"Task {prg_filename} failed and can be retried. Current retry count: {retry_count}") + except json.JSONDecodeError: + # Not valid JSON, ignore + pass + except Exception as e: + print(f"Error checking task completion status: {e}") + + # Get the current count of running downloads with the lock held + running_count = len(self.running_downloads) + + # Log current capacity for debugging + print(f"Queue status: {running_count}/{self.max_concurrent} running, {self.pending_tasks.qsize()} pending, paused: {self.paused}") + + # Start new tasks if there is available capacity and not paused. + if running_count < self.max_concurrent and not self.paused: + try: + # Try to get a new task, but don't block for too long + prg_filename, task = self.pending_tasks.get(timeout=1) + except Empty: + time.sleep(0.5) continue - prg_path = task.get('prg_path') - # Create and start a new process for the task. - p = Process( - target=run_download_task, - args=(task, prg_path) - ) - with self.lock: - self.running_downloads[prg_filename] = p - p.start() - else: - # At capacity; sleep briefly. - time.sleep(1) + # Check if the task was cancelled while it was still queued. + with self.lock: + if prg_filename in self.cancelled_tasks: + # Task has been cancelled; remove it from the set and skip processing. + self.cancelled_tasks.remove(prg_filename) + print(f"Task {prg_filename} was cancelled while queued, skipping") + continue + prg_path = task.get('prg_path') + + # Write a status update that the task is now processing + try: + with open(prg_path, 'a') as f: + f.write(json.dumps({ + "status": "processing", + "timestamp": time.time() + }) + "\n") + except Exception as e: + print(f"Error writing processing status: {e}") + + # Create a stop event for graceful shutdown + stop_event = Event() + + # Create and start a new process for the task. + p = Process( + target=run_download_task, + args=(task, prg_path, stop_event) + ) + with self.lock: + self.running_downloads[prg_filename] = (p, task, stop_event) + p.start() + print(f"Started download process for {prg_filename}") + else: + # At capacity or paused; sleep briefly. + time.sleep(1) + except Exception as e: + print(f"Error in queue worker: {e}") + traceback.print_exc() + # Small sleep to avoid a tight loop. time.sleep(0.1) + + # Periodically save queue state + if random.randint(1, 100) == 1: # ~1% chance each iteration + self.save_queue_state() # ------------------------------------------------------------------------------ # Global Instance diff --git a/routes/utils/search.py b/routes/utils/search.py index 7d1072f..e935366 100755 --- a/routes/utils/search.py +++ b/routes/utils/search.py @@ -14,15 +14,13 @@ def search( if main: search_creds_path = Path(f'./creds/spotify/{main}/search.json') - print(search_creds_path) + if search_creds_path.exists(): try: with open(search_creds_path, 'r') as f: search_creds = json.load(f) client_id = search_creds.get('client_id') - print(client_id) client_secret = search_creds.get('client_secret') - print(client_secret) except Exception as e: print(f"Error loading search credentials: {e}") diff --git a/routes/utils/track.py b/routes/utils/track.py index 6426553..22a9a44 100755 --- a/routes/utils/track.py +++ b/routes/utils/track.py @@ -14,7 +14,11 @@ def download_track( fall_quality=None, real_time=False, custom_dir_format="%ar_album%/%album%/%copyright%", - custom_track_format="%tracknum%. %music% - %artist%" + custom_track_format="%tracknum%. %music% - %artist%", + pad_tracks=True, + initial_retry_delay=5, + retry_delay_increase=5, + max_retries=3 ): try: # Load Spotify client credentials if available @@ -56,7 +60,10 @@ def download_track( not_interface=False, method_save=1, custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format + custom_track_format=custom_track_format, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries ) except Exception as e: # If the first attempt fails, use the fallback Spotify credentials @@ -91,7 +98,11 @@ def download_track( method_save=1, real_time_dl=real_time, custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries ) else: # Directly use Spotify main account @@ -114,7 +125,11 @@ def download_track( method_save=1, real_time_dl=real_time, custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries ) elif service == 'deezer': if quality is None: @@ -137,7 +152,11 @@ def download_track( recursive_download=False, method_save=1, custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format + custom_track_format=custom_track_format, + pad_tracks=pad_tracks, + initial_retry_delay=initial_retry_delay, + retry_delay_increase=retry_delay_increase, + max_retries=max_retries ) else: raise ValueError(f"Unsupported service: {service}") diff --git a/static/css/config/config.css b/static/css/config/config.css index e6a95b4..afc5d17 100644 --- a/static/css/config/config.css +++ b/static/css/config/config.css @@ -201,6 +201,14 @@ input:checked + .slider:before { transform: translateX(20px); } +/* Setting description */ +.setting-description { + margin-top: 0.4rem; + font-size: 0.8rem; + color: #b3b3b3; + line-height: 1.4; +} + /* Service Tabs */ .service-tabs { display: flex; @@ -387,6 +395,16 @@ input:checked + .slider:before { min-height: 1.2rem; } +/* Success Messages */ +#configSuccess { + color: #1db954; + margin-top: 1rem; + text-align: center; + font-size: 0.9rem; + min-height: 1.2rem; + font-weight: 500; +} + /* MOBILE RESPONSIVENESS */ @media (max-width: 768px) { .config-container { diff --git a/static/images/placeholder.jpg b/static/images/placeholder.jpg new file mode 100644 index 0000000000000000000000000000000000000000..029b4dfe74ce727ba3b82f128716f7cb7b24f254 GIT binary patch literal 5089 zcmeHLcU)81w%#caI!1~hB|t!F4o&(%0+@gWsfh>{u!5p=DGEV~UQ5PW>xRT6@N5}I<-a+?3|#(4>ld=M-I3xg;DC=vogLO8Dhio0_j zF5*8xbH`8^oQD^|$1fnrolv>~Kp`*~6b|Fzfpeil!nyYW9Lcj$QOAN;%)tYp-}>(y)5Z`@41m6>(#es)f79_`WNlF}z- z@K)I<#-d{yAV#{}Qr4fc-D74?qY8;l4Z=5@5hwy=rEpz<=ydK2V0FVC8cz+Ut*H z`W|;A^)y7}W5wE>Z#zDblfo=Md|XPJ7mc~HS=Gz?A@C7d7(0}vOM!h;(>+shHDhnw84-y zTi|IM>?2}jFoAk?C!Jn;vLUz6e1|*Utiq>}17e*rBV}9Acb@5?<}YKKZ{x&)!@BKj zPBrvGuLg@wIy|`>XyWa&;Kl)DXr#K+wNb09C!E-I=q0CtfpJWg*hMC<^|(=PE_Bp2 z@s+y`JbLibDt(a{V|Z@XUE@r^s#f)?DN#-w($=UmIrW=ZP2Z$;k3f zVpErqSkX+fD)?uOwqWScn-D(9x3q8h*6#fKWe0?$r7mQ>>+90fGnG8DTP(#c3i6^dP6o~vnb6Z01=2^o%=?2;%e@iiN$Gh_z{}au&8^|?OcCk1 zkx!#Dl4;NsIecX$fzlYTOehKTtB`gH%R?;L^B(&UP_$=TIVq^z=Ov4#SrC&Cl?sBp zcjFwI;Ce;M2htjiJXqTy7wyRV72Riw5mg!TG0tU@DZO{r7_&7Z1Fs2Tp!dnsq@klz zwTQ;54gz_CkSe8{A|)GTN^B}@@B2vvPB!=WIHd}05_iDz{3FBtTnG35+?`38HoU*v zG^@K{xjh+iOK4FX*w*5hLF&NqWR5O)?H95PvoL0E5YwymxZAIbE zXD~Nul%w0N!%K3|#aw6jxo)@Ty|C|7;#j{LQTFUx%yfn;$OrWnh%43#9RU{NA4|I# zHPfOQ1suS-YsLXpFw{#9xX1w*tgs~v$_GbAi7Jq5pFh?fx2e{V&&so7m!86D1}Fp? zBW4k;?`q*2unGwq!jM?mF%!d({(~aYd#x7R*~b@NB4`x6|zIWR^qMBlYtY7kWnBBM7FO z80rE)OukN&V{-Sdgr7@isOudK+N5o_cq{{R9p`xozfzFHuwXlvKyGRMG^BX;sB+j4 zY!O+V-)1Y+@YJ9smto!E)I+Ey+Si9g>1vptHdgd8>3G;%=g<*u7I77lWm8{NPyiuH zDRiE9E4_2ZTa)T0h8LY~a%F?AhmAGlS?L z2=0_0I9Yo2i`%GK**SVwq*lY`T@;x>k0trnMDgU3y)$J5qve%u3uk?)V5LTc+SjU; zoD2>yFM-@}ofivD4)u=kd}yBkf$M>t7xG4qL=a777@jcyzw%yY#jo{I`=&Mt&iyQK zJ(p=0pJ_L3jESPKE5Y$^r15{#3^-unS3sQ^$QL|*uEzWjVr2(xN!}A$X(!R&xWl7C zbgHTmbI8lJ{xklFD3fTc3~?dZE*7Jl-oG`PJ(;iH47GsEB5ReN zXcj#mdq1%d&keqh?ClVVv1f`adoUVmeZwS{;@YXsl8--oENc(bGxFl$8$uD$w;K2K z42W~Ub;lvmtUyfMF7S?K60;w7H^j3Rp7dZvL*xto=v!KH}dA>C=f8@nODoy0kKudhpB zvQ-2+nxF~`8?ZCrORqR!*x`H1H7onYk!zTzLu?_;a%b3|D#&*gwZ7xF?(3?43MLgZ z>{?36mF+8gq_C!kmH56Os1f$}INU7mwO;6CV@Wu%FUdwqei5Cmn_&UvaDY0W^GKWkX`@PlT0Ihm1Ay$Qc5nXh3}Q4tK{~ExpxeHNN3e>fD&nb zJD{u-;zYf4IDjHt)m)Mmb z1h^{>woEj-gujt->Jyne^fzI%&4om-Cq3nW`X{H+d!@^_t`rxqVGF~4$YARcq+#fE z$enb;Y0;hBPAi*y(N76<=YZB$Uj4)GP@=k|P%F_ze)=3uLcbu*c}8dQ+39c@GUzYp zrSuJ2X?=OoZr44kf8TkIuk4kq$Pr@>h)fe@Q3bCiz<8b7E+QBqxrN7PsfmwgA*CEJ zAfXRV^ypj)EljU4jmT#!mC#~WH3bgP7p^2O&NDhs4IEF?_v_B0;Hg3l9|OcM;ZV=6 zOfdT2I2XmXDHE-wiA)Xo@pcz}1xdHW3%_HgzjJB|Yi zU4vY|4z^I#F(IiMQ>KNw^%ITqd*U|cz|34knX1rX5XD!EhRY9i9k{$L;e~goc5$^_ zRX?V^R81fsS^$B&N7rr#wI|3yLUByfJeJbEf=Tc3*yp`O%+y5kC%q1*%#M9i&9PFx zHV9PwN9@Liqh}PAp>b$zO{WWYfkbnYW~P-*7(0X`Vkw zsfe6=O0qXDEqdJHGMGY;YF1fPp{L!&6|?quF6|q&%&1Q97tj5aP?RH-qtJ@LITwB2 zPf6$9kzWT}Ebcqc0qF%@-DAE<^lqT$#h1I1^Bp zy-(g8JwER)vveeNUSn+K2FflC$0ppzfq<(HI3WI=spp%jt2XEW(EzvB*AL$&Q-)>J z9m8(r<@BDBKTk=$gho8%ZU{Bo^`Q0dGS#0Lj2AbS7n=}|38b?Zx0bzY&+Fb%Um);s zT~%3XC`~m{Ro-nuFFh!x3e--^AS|s!Vu+QcqXChHu{Yj5K3{m$0V^kGU^8;FsFM46bk3A{8I^!s8*}A|ji)-pNIKeeWTh)L)=`Q}vHqq7M!!>BG zQkMH(x*m93s;8YypSsEcgU8M-hqb%zFBvr*-GMQfmnfpqpt%4NiNLBtJbxfozpsYh z#tIuOq!Ce~Ryc9U?EsI?A^C9(d&}c^g)0_tUyC6!gDL&lJ$+ip@Z=%U5v-Imt?{02 z3LnFwdQH8t%Mf`icUG)3jIGf{Yvrk(6O4Iz!NzRSr;!xvW{dB6u^4DRf@V#y3dpLk zptFT;_v$zRYD!|Mhg=tHw`pm+Ua|b=FPBo`#MkATKbOp%t%{nX3Q#v5S|75aXvK$l$G;;1ee8#iyF(W1aD=Hy@Q_Rut$=(yJ0rDqmJv c*)LOg2zzF%O3$(FZ{z;^JotASlQ=#91-l { return; } - // Fetch the config to get active Spotify account first - fetch('/api/config') - .then(response => { - if (!response.ok) throw new Error('Failed to fetch config'); - return response.json(); - }) - .then(config => { - const mainAccount = config.spotify || ''; - - // Then fetch album info with the main parameter - return fetch(`/api/album/info?id=${encodeURIComponent(albumId)}&main=${mainAccount}`); - }) + // Fetch album info directly + fetch(`/api/album/info?id=${encodeURIComponent(albumId)}`) .then(response => { if (!response.ok) throw new Error('Network response was not ok'); return response.json(); @@ -48,19 +38,21 @@ function renderAlbum(album) { // Set album header info. document.getElementById('album-name').innerHTML = - `${album.name}`; + `${album.name || 'Unknown Album'}`; document.getElementById('album-artist').innerHTML = - `By ${album.artists.map(artist => `${artist.name}`).join(', ')}`; + `By ${album.artists?.map(artist => + `${artist?.name || 'Unknown Artist'}` + ).join(', ') || 'Unknown Artist'}`; - const releaseYear = new Date(album.release_date).getFullYear(); + const releaseYear = album.release_date ? new Date(album.release_date).getFullYear() : 'N/A'; document.getElementById('album-stats').textContent = - `${releaseYear} • ${album.total_tracks} songs • ${album.label}`; + `${releaseYear} • ${album.total_tracks || '0'} songs • ${album.label || 'Unknown Label'}`; document.getElementById('album-copyright').textContent = - album.copyrights.map(c => c.text).join(' • '); + album.copyrights?.map(c => c?.text || '').filter(text => text).join(' • ') || ''; - const image = album.images[0]?.url || 'placeholder.jpg'; + const image = album.images?.[0]?.url || '/static/images/placeholder.jpg'; document.getElementById('album-image').src = image; // Create (if needed) the Home Button. @@ -107,7 +99,7 @@ function renderAlbum(album) { downloadAlbumBtn.textContent = 'Queued!'; }) .catch(err => { - showError('Failed to queue album download: ' + err.message); + showError('Failed to queue album download: ' + (err?.message || 'Unknown error')); downloadAlbumBtn.disabled = false; }); }); @@ -116,30 +108,36 @@ function renderAlbum(album) { const tracksList = document.getElementById('tracks-list'); tracksList.innerHTML = ''; - album.tracks.items.forEach((track, index) => { - const trackElement = document.createElement('div'); - trackElement.className = 'track'; - trackElement.innerHTML = ` -
${index + 1}
-
-
- ${track.name} + if (album.tracks?.items) { + album.tracks.items.forEach((track, index) => { + if (!track) return; // Skip null or undefined tracks + + const trackElement = document.createElement('div'); + trackElement.className = 'track'; + trackElement.innerHTML = ` +
${index + 1}
+
+ +
+ ${track.artists?.map(a => + `${a?.name || 'Unknown Artist'}` + ).join(', ') || 'Unknown Artist'} +
-
- ${track.artists.map(a => `${a.name}`).join(', ')} -
-
-
${msToTime(track.duration_ms)}
- - `; - tracksList.appendChild(trackElement); - }); +
${msToTime(track.duration_ms || 0)}
+ + `; + tracksList.appendChild(trackElement); + }); + } // Reveal header and track list. document.getElementById('album-header').classList.remove('hidden'); @@ -166,11 +164,15 @@ function renderAlbum(album) { } async function downloadWholeAlbum(album) { - const url = album.external_urls.spotify; + const url = album.external_urls?.spotify || ''; + if (!url) { + throw new Error('Missing album URL'); + } + try { - await downloadQueue.startAlbumDownload(url, { name: album.name }); + await downloadQueue.startAlbumDownload(url, { name: album.name || 'Unknown Album' }); } catch (error) { - showError('Album download failed: ' + error.message); + showError('Album download failed: ' + (error?.message || 'Unknown error')); throw error; } } @@ -183,7 +185,7 @@ function msToTime(duration) { function showError(message) { const errorEl = document.getElementById('error'); - errorEl.textContent = message; + errorEl.textContent = message || 'An error occurred'; errorEl.classList.remove('hidden'); } @@ -192,9 +194,9 @@ function attachDownloadListeners() { if (btn.id === 'downloadAlbumBtn') return; btn.addEventListener('click', (e) => { e.stopPropagation(); - const url = e.currentTarget.dataset.url; - const type = e.currentTarget.dataset.type; - const name = e.currentTarget.dataset.name || extractName(url); + const url = e.currentTarget.dataset.url || ''; + const type = e.currentTarget.dataset.type || ''; + const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown'; // Remove the button immediately after click. e.currentTarget.remove(); startDownload(url, type, { name }); @@ -203,47 +205,25 @@ function attachDownloadListeners() { } async function startDownload(url, type, item, albumType) { - const config = JSON.parse(localStorage.getItem('activeConfig')) || {}; - const { - fallback = false, - spotify = '', - deezer = '', - spotifyQuality = 'NORMAL', - deezerQuality = 'MP3_128', - realTime = false, - customDirFormat = '', - customTrackFormat = '' - } = config; - + if (!url) { + showError('Missing URL for download'); + return; + } + const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer'; - let apiUrl = ''; + let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`; - if (type === 'album') { - apiUrl = `/api/album/download?service=${service}&url=${encodeURIComponent(url)}`; - } else if (type === 'artist') { - apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumType || 'album,single,compilation')}`; - } else { - apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`; + // Add name and artist if available for better progress display + if (item.name) { + apiUrl += `&name=${encodeURIComponent(item.name)}`; } - - if (fallback && service === 'spotify') { - apiUrl += `&main=${deezer}&fallback=${spotify}`; - apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`; - } else { - const mainAccount = service === 'spotify' ? spotify : deezer; - apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`; + if (item.artist) { + apiUrl += `&artist=${encodeURIComponent(item.artist)}`; } - - if (realTime) { - apiUrl += '&real_time=true'; - } - - // Append custom directory and file format settings if provided. - if (customDirFormat) { - apiUrl += `&custom_dir_format=${encodeURIComponent(customDirFormat)}`; - } - if (customTrackFormat) { - apiUrl += `&custom_file_format=${encodeURIComponent(customTrackFormat)}`; + + // For artist downloads, include album_type + if (type === 'artist' && albumType) { + apiUrl += `&album_type=${encodeURIComponent(albumType)}`; } try { @@ -251,10 +231,10 @@ async function startDownload(url, type, item, albumType) { const data = await response.json(); downloadQueue.addDownload(item, type, data.prg_file); } catch (error) { - showError('Download failed: ' + error.message); + showError('Download failed: ' + (error?.message || 'Unknown error')); } } function extractName(url) { - return url; + return url || 'Unknown'; } diff --git a/static/js/artist.js b/static/js/artist.js index fe76927..ef4bd33 100644 --- a/static/js/artist.js +++ b/static/js/artist.js @@ -10,18 +10,8 @@ document.addEventListener('DOMContentLoaded', () => { return; } - // Fetch the config to get active Spotify account first - fetch('/api/config') - .then(response => { - if (!response.ok) throw new Error('Failed to fetch config'); - return response.json(); - }) - .then(config => { - const mainAccount = config.spotify || ''; - - // Then fetch artist info with the main parameter - return fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}&main=${mainAccount}`); - }) + // Fetch artist info directly + fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`) .then(response => { if (!response.ok) throw new Error('Network response was not ok'); return response.json(); @@ -42,13 +32,13 @@ function renderArtist(artistData, artistId) { document.getElementById('loading').classList.add('hidden'); document.getElementById('error').classList.add('hidden'); - const firstAlbum = artistData.items[0]; - const artistName = firstAlbum?.artists[0]?.name || 'Unknown Artist'; - const artistImage = firstAlbum?.images[0]?.url || 'placeholder.jpg'; + const firstAlbum = artistData.items?.[0] || {}; + const artistName = firstAlbum?.artists?.[0]?.name || 'Unknown Artist'; + const artistImage = firstAlbum?.images?.[0]?.url || '/static/images/placeholder.jpg'; document.getElementById('artist-name').innerHTML = `${artistName}`; - document.getElementById('artist-stats').textContent = `${artistData.total} albums`; + document.getElementById('artist-stats').textContent = `${artistData.total || '0'} albums`; document.getElementById('artist-image').src = artistImage; // Define the artist URL (used by both full-discography and group downloads) @@ -93,13 +83,14 @@ function renderArtist(artistData, artistId) { .catch(err => { downloadArtistBtn.textContent = 'Download All Discography'; downloadArtistBtn.disabled = false; - showError('Failed to queue artist download: ' + err.message); + showError('Failed to queue artist download: ' + (err?.message || 'Unknown error')); }); }); // Group albums by type (album, single, compilation, etc.) - const albumGroups = artistData.items.reduce((groups, album) => { - const type = album.album_type.toLowerCase(); + const albumGroups = (artistData.items || []).reduce((groups, album) => { + if (!album) return groups; + const type = (album.album_type || 'unknown').toLowerCase(); if (!groups[type]) groups[type] = []; groups[type].push(album); return groups; @@ -126,22 +117,24 @@ function renderArtist(artistData, artistId) { const albumsContainer = groupSection.querySelector('.albums-list'); albums.forEach(album => { + if (!album) return; + const albumElement = document.createElement('div'); albumElement.className = 'album-card'; albumElement.innerHTML = ` - - + Album cover
-
${album.name}
-
${album.artists.map(a => a.name).join(', ')}
+
${album.name || 'Unknown Album'}
+
${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}
@@ -164,7 +157,7 @@ function renderArtist(artistData, artistId) { function attachGroupDownloadListeners(artistUrl, artistName) { document.querySelectorAll('.group-download-btn').forEach(btn => { btn.addEventListener('click', async (e) => { - const groupType = e.target.dataset.groupType; // e.g. "album", "single", "compilation" + const groupType = e.target.dataset.groupType || 'album'; // e.g. "album", "single", "compilation" e.target.disabled = true; e.target.textContent = `Queueing all ${capitalize(groupType)}s...`; @@ -172,14 +165,14 @@ function attachGroupDownloadListeners(artistUrl, artistName) { // Use the artist download function with the group type filter. await downloadQueue.startArtistDownload( artistUrl, - { name: artistName, artist: artistName }, + { name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' }, groupType // Only queue releases of this specific type. ); e.target.textContent = `Queued all ${capitalize(groupType)}s`; } catch (error) { e.target.textContent = `Download All ${capitalize(groupType)}s`; e.target.disabled = false; - showError(`Failed to queue download for all ${groupType}s: ${error.message}`); + showError(`Failed to queue download for all ${groupType}s: ${error?.message || 'Unknown error'}`); } }); }); @@ -190,10 +183,13 @@ function attachDownloadListeners() { document.querySelectorAll('.download-btn:not(.group-download-btn)').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); - const { url, name, type } = e.currentTarget.dataset; + const url = e.currentTarget.dataset.url || ''; + const name = e.currentTarget.dataset.name || 'Unknown'; + const type = e.currentTarget.dataset.type || 'album'; + e.currentTarget.remove(); downloadQueue.startAlbumDownload(url, { name, type }) - .catch(err => showError('Download failed: ' + err.message)); + .catch(err => showError('Download failed: ' + (err?.message || 'Unknown error'))); }); }); } @@ -201,8 +197,10 @@ function attachDownloadListeners() { // UI Helpers function showError(message) { const errorEl = document.getElementById('error'); - errorEl.textContent = message; - errorEl.classList.remove('hidden'); + if (errorEl) { + errorEl.textContent = message || 'An error occurred'; + errorEl.classList.remove('hidden'); + } } function capitalize(str) { diff --git a/static/js/config.js b/static/js/config.js index 2ff2726..180c806 100644 --- a/static/js/config.js +++ b/static/js/config.js @@ -83,6 +83,9 @@ function setupEventListeners() { document.getElementById('realTimeToggle').addEventListener('change', saveConfig); document.getElementById('spotifyQualitySelect').addEventListener('change', saveConfig); document.getElementById('deezerQualitySelect').addEventListener('change', saveConfig); + document.getElementById('tracknumPaddingToggle').addEventListener('change', saveConfig); + document.getElementById('maxRetries').addEventListener('change', saveConfig); + document.getElementById('retryDelaySeconds').addEventListener('change', saveConfig); // Update active account globals when the account selector is changed. document.getElementById('spotifyAccountSelect').addEventListener('change', (e) => { @@ -350,11 +353,41 @@ function toggleSearchFieldsVisibility(showSearchFields) { const searchFieldsDiv = document.getElementById('searchFields'); if (showSearchFields) { + // Hide regular fields and remove 'required' attribute serviceFieldsDiv.style.display = 'none'; + // Remove required attribute from service fields + serviceConfig[currentService].fields.forEach(field => { + const input = document.getElementById(field.id); + if (input) input.removeAttribute('required'); + }); + + // Show search fields and add 'required' attribute searchFieldsDiv.style.display = 'block'; + // Make search fields required + if (currentService === 'spotify' && serviceConfig[currentService].searchFields) { + serviceConfig[currentService].searchFields.forEach(field => { + const input = document.getElementById(field.id); + if (input) input.setAttribute('required', ''); + }); + } } else { + // Show regular fields and add 'required' attribute serviceFieldsDiv.style.display = 'block'; + // Make service fields required + serviceConfig[currentService].fields.forEach(field => { + const input = document.getElementById(field.id); + if (input) input.setAttribute('required', ''); + }); + + // Hide search fields and remove 'required' attribute searchFieldsDiv.style.display = 'none'; + // Remove required from search fields + if (currentService === 'spotify' && serviceConfig[currentService].searchFields) { + serviceConfig[currentService].searchFields.forEach(field => { + const input = document.getElementById(field.id); + if (input) input.removeAttribute('required'); + }); + } } } @@ -431,9 +464,25 @@ async function handleCredentialSubmit(e) { if (isEditingSearch && service === 'spotify') { // Handle search credentials const formData = {}; + let isValid = true; + let firstInvalidField = null; + + // Manually validate search fields serviceConfig[service].searchFields.forEach(field => { - formData[field.id] = document.getElementById(field.id).value.trim(); + const input = document.getElementById(field.id); + const value = input ? input.value.trim() : ''; + formData[field.id] = value; + + if (!value) { + isValid = false; + if (!firstInvalidField) firstInvalidField = input; + } }); + + if (!isValid) { + if (firstInvalidField) firstInvalidField.focus(); + throw new Error('All fields are required'); + } data = serviceConfig[service].searchValidator(formData); endpoint = `/api/credentials/${service}/${endpointName}?type=search`; @@ -444,9 +493,25 @@ async function handleCredentialSubmit(e) { } else { // Handle regular account credentials const formData = {}; + let isValid = true; + let firstInvalidField = null; + + // Manually validate account fields serviceConfig[service].fields.forEach(field => { - formData[field.id] = document.getElementById(field.id).value.trim(); + const input = document.getElementById(field.id); + const value = input ? input.value.trim() : ''; + formData[field.id] = value; + + if (!value) { + isValid = false; + if (!firstInvalidField) firstInvalidField = input; + } }); + + if (!isValid) { + if (firstInvalidField) firstInvalidField.focus(); + throw new Error('All fields are required'); + } data = serviceConfig[service].validator(formData); endpoint = `/api/credentials/${service}/${endpointName}`; @@ -468,6 +533,9 @@ async function handleCredentialSubmit(e) { await saveConfig(); loadCredentials(service); resetForm(); + + // Show success message + showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully'); } catch (error) { showConfigError(error.message); } @@ -501,7 +569,11 @@ async function saveConfig() { realTime: document.getElementById('realTimeToggle').checked, customDirFormat: document.getElementById('customDirFormat').value, customTrackFormat: document.getElementById('customTrackFormat').value, - maxConcurrentDownloads: parseInt(document.getElementById('maxConcurrentDownloads').value, 10) || 3 + maxConcurrentDownloads: parseInt(document.getElementById('maxConcurrentDownloads').value, 10) || 3, + maxRetries: parseInt(document.getElementById('maxRetries').value, 10) || 3, + retryDelaySeconds: parseInt(document.getElementById('retryDelaySeconds').value, 10) || 5, + retry_delay_increase: parseInt(document.getElementById('retryDelayIncrease').value, 10) || 5, + tracknum_padding: document.getElementById('tracknumPaddingToggle').checked }; try { @@ -546,6 +618,10 @@ async function loadConfig() { document.getElementById('customDirFormat').value = savedConfig.customDirFormat || '%ar_album%/%album%'; document.getElementById('customTrackFormat').value = savedConfig.customTrackFormat || '%tracknum%. %music%'; document.getElementById('maxConcurrentDownloads').value = savedConfig.maxConcurrentDownloads || '3'; + document.getElementById('maxRetries').value = savedConfig.maxRetries || '3'; + document.getElementById('retryDelaySeconds').value = savedConfig.retryDelaySeconds || '5'; + document.getElementById('retryDelayIncrease').value = savedConfig.retry_delay_increase || '5'; + document.getElementById('tracknumPaddingToggle').checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding; } catch (error) { showConfigError('Error loading config: ' + error.message); } @@ -556,3 +632,9 @@ function showConfigError(message) { errorDiv.textContent = message; setTimeout(() => (errorDiv.textContent = ''), 5000); } + +function showConfigSuccess(message) { + const successDiv = document.getElementById('configSuccess'); + successDiv.textContent = message; + setTimeout(() => (successDiv.textContent = ''), 5000); +} diff --git a/static/js/main.js b/static/js/main.js index 5cf7b0a..d83cdec 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -14,25 +14,42 @@ document.addEventListener('DOMContentLoaded', () => { } // Save the search type to local storage whenever it changes - searchType.addEventListener('change', () => { - localStorage.setItem('searchType', searchType.value); - }); + if (searchType) { + searchType.addEventListener('change', () => { + localStorage.setItem('searchType', searchType.value); + }); + } // Initialize queue icon - queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility()); + if (queueIcon) { + queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility()); + } // Search functionality - searchButton.addEventListener('click', performSearch); - searchInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') performSearch(); - }); + if (searchButton) { + searchButton.addEventListener('click', performSearch); + } + + if (searchInput) { + searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') performSearch(); + }); + } }); async function performSearch() { - const query = document.getElementById('searchInput').value.trim(); - const searchType = document.getElementById('searchType').value; + const searchInput = document.getElementById('searchInput'); + const searchType = document.getElementById('searchType'); const resultsContainer = document.getElementById('resultsContainer'); + if (!searchInput || !searchType || !resultsContainer) { + console.error('Required DOM elements not found'); + return; + } + + const query = searchInput.value.trim(); + const typeValue = searchType.value; + if (!query) { showError('Please enter a search term'); return; @@ -50,7 +67,7 @@ async function performSearch() { window.location.href = `${window.location.origin}/${type}/${id}`; return; } catch (error) { - showError(`Invalid Spotify URL: ${error.message}`); + showError(`Invalid Spotify URL: ${error?.message || 'Unknown error'}`); return; } } @@ -61,26 +78,27 @@ async function performSearch() { // Fetch config to get active Spotify account const configResponse = await fetch('/api/config'); const config = await configResponse.json(); - const mainAccount = config.spotify || ''; + const mainAccount = config?.spotify || ''; // Add the main parameter to the search API call - const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${searchType}&limit=50&main=${mainAccount}`); + const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${typeValue}&limit=50&main=${mainAccount}`); const data = await response.json(); if (data.error) throw new Error(data.error); // When mapping the items, include the index so that each card gets a data-index attribute. - const items = data.data[`${searchType}s`]?.items; + const items = data.data?.[`${typeValue}s`]?.items; if (!items?.length) { resultsContainer.innerHTML = '
No results found
'; return; } resultsContainer.innerHTML = items - .map((item, index) => createResultCard(item, searchType, index)) + .map((item, index) => item ? createResultCard(item, typeValue, index) : '') + .filter(card => card) // Filter out empty strings .join(''); attachDownloadListeners(items); } catch (error) { - showError(error.message); + showError(error?.message || 'Search failed'); } } @@ -93,21 +111,24 @@ function attachDownloadListeners(items) { document.querySelectorAll('.download-btn, .download-btn-small').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); - const url = e.currentTarget.dataset.url; - const type = e.currentTarget.dataset.type; - const albumType = e.currentTarget.dataset.albumType; + const url = e.currentTarget.dataset.url || ''; + const type = e.currentTarget.dataset.type || ''; + const albumType = e.currentTarget.dataset.albumType || ''; // Get the parent result card and its data-index const card = e.currentTarget.closest('.result-card'); const idx = card ? card.getAttribute('data-index') : null; - const item = (idx !== null) ? items[idx] : null; + const item = (idx !== null && items[idx]) ? items[idx] : null; // Remove the button or card from the UI as appropriate. if (e.currentTarget.classList.contains('main-download')) { - card.remove(); + if (card) card.remove(); } else { e.currentTarget.remove(); } - startDownload(url, type, item, albumType); + + if (url && type) { + startDownload(url, type, item, albumType); + } }); }); } @@ -118,13 +139,22 @@ function attachDownloadListeners(items) { * so that the backend endpoint (at /artist/download) receives the required query parameters. */ async function startDownload(url, type, item, albumType) { + if (!url || !type) { + showError('Missing URL or type for download'); + return; + } + // Enrich the item object with the artist property. - if (type === 'track' || type === 'album') { - item.artist = item.artists.map(a => a.name).join(', '); - } else if (type === 'playlist') { - item.artist = item.owner.display_name; - } else if (type === 'artist') { - item.artist = item.name; + if (item) { + if (type === 'track' || type === 'album') { + item.artist = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'; + } else if (type === 'playlist') { + item.artist = item.owner?.display_name || 'Unknown Owner'; + } else if (type === 'artist') { + item.artist = item.name || 'Unknown Artist'; + } + } else { + item = { name: 'Unknown', artist: 'Unknown Artist' }; } try { @@ -142,17 +172,20 @@ async function startDownload(url, type, item, albumType) { throw new Error(`Unsupported type: ${type}`); } } catch (error) { - showError('Download failed: ' + error.message); + showError('Download failed: ' + (error?.message || 'Unknown error')); } } // UI Helper Functions function showError(message) { - document.getElementById('resultsContainer').innerHTML = `
${message}
`; + const resultsContainer = document.getElementById('resultsContainer'); + if (resultsContainer) { + resultsContainer.innerHTML = `
${message || 'An error occurred'}
`; + } } function isSpotifyUrl(url) { - return url.startsWith('https://open.spotify.com/'); + return url && url.startsWith('https://open.spotify.com/'); } /** @@ -160,6 +193,8 @@ function isSpotifyUrl(url) { * Expected URL format: https://open.spotify.com/{type}/{id} */ function getSpotifyResourceDetails(url) { + if (!url) throw new Error('Empty URL provided'); + const urlObj = new URL(url); const pathParts = urlObj.pathname.split('/'); if (pathParts.length < 3 || !pathParts[1] || !pathParts[2]) { @@ -172,6 +207,8 @@ function getSpotifyResourceDetails(url) { } function msToMinutesSeconds(ms) { + if (!ms || isNaN(ms)) return '0:00'; + const minutes = Math.floor(ms / 60000); const seconds = ((ms % 60000) / 1000).toFixed(0); return `${minutes}:${seconds.padStart(2, '0')}`; @@ -182,11 +219,15 @@ function msToMinutesSeconds(ms) { * The additional parameter "index" is used to set a data-index attribute on the card. */ function createResultCard(item, type, index) { + if (!item) return ''; + let newUrl = '#'; try { - const spotifyUrl = item.external_urls.spotify; - const parsedUrl = new URL(spotifyUrl); - newUrl = window.location.origin + parsedUrl.pathname; + const spotifyUrl = item.external_urls?.spotify; + if (spotifyUrl) { + const parsedUrl = new URL(spotifyUrl); + newUrl = window.location.origin + parsedUrl.pathname; + } } catch (e) { console.error('Error parsing URL:', e); } @@ -195,15 +236,15 @@ function createResultCard(item, type, index) { switch (type) { case 'track': - imageUrl = item.album.images[0]?.url || ''; - title = item.name; - subtitle = item.artists.map(a => a.name).join(', '); + imageUrl = item.album?.images?.[0]?.url || '/static/images/placeholder.jpg'; + title = item.name || 'Unknown Track'; + subtitle = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'; details = ` - ${item.album.name} + ${item.album?.name || 'Unknown Album'} ${msToMinutesSeconds(item.duration_ms)} `; return ` -
+
${type} cover
@@ -211,7 +252,7 @@ function createResultCard(item, type, index) {
${title}
`; case 'playlist': - imageUrl = item.images[0]?.url || ''; - title = item.name; - subtitle = item.owner.display_name; + imageUrl = item.images?.[0]?.url || '/static/images/placeholder.jpg'; + title = item.name || 'Unknown Playlist'; + subtitle = item.owner?.display_name || 'Unknown Owner'; details = ` - ${item.tracks.total} tracks + ${item.tracks?.total || '0'} tracks ${item.description || 'No description'} `; return ` -
+
${type} cover
@@ -242,7 +283,7 @@ function createResultCard(item, type, index) {
${title}
`; case 'album': - imageUrl = item.images[0]?.url || ''; - title = item.name; - subtitle = item.artists.map(a => a.name).join(', '); + imageUrl = item.images?.[0]?.url || '/static/images/placeholder.jpg'; + title = item.name || 'Unknown Album'; + subtitle = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'; details = ` - ${item.release_date} - ${item.total_tracks} tracks + ${item.release_date || 'Unknown release date'} + ${item.total_tracks || '0'} tracks `; return ` -
+
${type} cover
@@ -273,7 +314,7 @@ function createResultCard(item, type, index) {
${title}
`; case 'artist': - imageUrl = (item.images && item.images.length) ? item.images[0].url : ''; - title = item.name; + imageUrl = (item.images && item.images.length) ? item.images[0].url : '/static/images/placeholder.jpg'; + title = item.name || 'Unknown Artist'; subtitle = (item.genres && item.genres.length) ? item.genres.join(', ') : 'Unknown genres'; details = `Followers: ${item.followers?.total || 'N/A'}`; return ` -
+
${type} cover
@@ -302,7 +343,7 @@ function createResultCard(item, type, index) {
- `; - tracksList.appendChild(trackElement); - }); +
${msToTime(track.duration_ms || 0)}
+ + `; + tracksList.appendChild(trackElement); + }); + } // Reveal header and tracks container document.getElementById('playlist-header').classList.remove('hidden'); @@ -187,6 +189,8 @@ function renderPlaylist(playlist) { * Converts milliseconds to minutes:seconds. */ function msToTime(duration) { + if (!duration || isNaN(duration)) return '0:00'; + const minutes = Math.floor(duration / 60000); const seconds = ((duration % 60000) / 1000).toFixed(0); return `${minutes}:${seconds.padStart(2, '0')}`; @@ -197,8 +201,10 @@ function msToTime(duration) { */ function showError(message) { const errorEl = document.getElementById('error'); - errorEl.textContent = message; - errorEl.classList.remove('hidden'); + if (errorEl) { + errorEl.textContent = message || 'An error occurred'; + errorEl.classList.remove('hidden'); + } } /** @@ -210,9 +216,9 @@ function attachDownloadListeners() { if (btn.id === 'downloadPlaylistBtn' || btn.id === 'downloadAlbumsBtn') return; btn.addEventListener('click', (e) => { e.stopPropagation(); - const url = e.currentTarget.dataset.url; - const type = e.currentTarget.dataset.type; - const name = e.currentTarget.dataset.name || extractName(url); + const url = e.currentTarget.dataset.url || ''; + const type = e.currentTarget.dataset.type || ''; + const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown'; // Remove the button immediately after click. e.currentTarget.remove(); startDownload(url, type, { name }); @@ -224,11 +230,19 @@ function attachDownloadListeners() { * Initiates the whole playlist download by calling the playlist endpoint. */ async function downloadWholePlaylist(playlist) { - const url = playlist.external_urls.spotify; + if (!playlist) { + throw new Error('Invalid playlist data'); + } + + const url = playlist.external_urls?.spotify || ''; + if (!url) { + throw new Error('Missing playlist URL'); + } + try { - await downloadQueue.startPlaylistDownload(url, { name: playlist.name }); + await downloadQueue.startPlaylistDownload(url, { name: playlist.name || 'Unknown Playlist' }); } catch (error) { - showError('Playlist download failed: ' + error.message); + showError('Playlist download failed: ' + (error?.message || 'Unknown error')); throw error; } } @@ -239,9 +253,16 @@ async function downloadWholePlaylist(playlist) { * with the progress (queued_albums/total_albums). */ async function downloadPlaylistAlbums(playlist) { + if (!playlist?.tracks?.items) { + showError('No tracks found in this playlist.'); + return; + } + // Build a map of unique albums (using album ID as the key). const albumMap = new Map(); playlist.tracks.items.forEach(item => { + if (!item?.track?.album) return; + const album = item.track.album; if (album && album.id) { albumMap.set(album.id, album); @@ -266,9 +287,14 @@ async function downloadPlaylistAlbums(playlist) { // Process each album sequentially. for (let i = 0; i < totalAlbums; i++) { const album = uniqueAlbums[i]; + if (!album) continue; + + const albumUrl = album.external_urls?.spotify || ''; + if (!albumUrl) continue; + await downloadQueue.startAlbumDownload( - album.external_urls.spotify, - { name: album.name } + albumUrl, + { name: album.name || 'Unknown Album' } ); // Update button text with current progress. @@ -291,56 +317,29 @@ async function downloadPlaylistAlbums(playlist) { } /** - * Starts the download process by building the API URL, - * fetching download details, and then adding the download to the queue. + * Starts the download process by building a minimal API URL with only the necessary parameters, + * since the server will use config defaults for others. */ async function startDownload(url, type, item, albumType) { - // Retrieve configuration (if any) from localStorage. - const config = JSON.parse(localStorage.getItem('activeConfig')) || {}; - const { - fallback = false, - spotify = '', - deezer = '', - spotifyQuality = 'NORMAL', - deezerQuality = 'MP3_128', - realTime = false, - customTrackFormat = '', - customDirFormat = '' - } = config; - + if (!url || !type) { + showError('Missing URL or type for download'); + return; + } + const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer'; - let apiUrl = ''; + let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`; - // Build API URL based on the download type. - if (type === 'playlist') { - // Use the dedicated playlist download endpoint. - apiUrl = `/api/playlist/download?service=${service}&url=${encodeURIComponent(url)}`; - } else if (type === 'artist') { - apiUrl = `/api/artist/download?service=${service}&artist_url=${encodeURIComponent(url)}&album_type=${encodeURIComponent(albumType || 'album,single,compilation')}`; - } else { - // Default is track download. - apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`; + // Add name and artist if available for better progress display + if (item.name) { + apiUrl += `&name=${encodeURIComponent(item.name)}`; } - - // Append account and quality details. - if (fallback && service === 'spotify') { - apiUrl += `&main=${deezer}&fallback=${spotify}`; - apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`; - } else { - const mainAccount = service === 'spotify' ? spotify : deezer; - apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`; + if (item.artist) { + apiUrl += `&artist=${encodeURIComponent(item.artist)}`; } - - if (realTime) { - apiUrl += '&real_time=true'; - } - - // Append custom formatting parameters. - if (customTrackFormat) { - apiUrl += `&custom_track_format=${encodeURIComponent(customTrackFormat)}`; - } - if (customDirFormat) { - apiUrl += `&custom_dir_format=${encodeURIComponent(customDirFormat)}`; + + // For artist downloads, include album_type + if (type === 'artist' && albumType) { + apiUrl += `&album_type=${encodeURIComponent(albumType)}`; } try { @@ -349,7 +348,7 @@ async function startDownload(url, type, item, albumType) { // Add the download to the queue using the working queue implementation. downloadQueue.addDownload(item, type, data.prg_file); } catch (error) { - showError('Download failed: ' + error.message); + showError('Download failed: ' + (error?.message || 'Unknown error')); } } @@ -357,5 +356,5 @@ async function startDownload(url, type, item, albumType) { * A helper function to extract a display name from the URL. */ function extractName(url) { - return url; + return url || 'Unknown'; } diff --git a/static/js/queue.js b/static/js/queue.js index 0d12d9d..54920cc 100644 --- a/static/js/queue.js +++ b/static/js/queue.js @@ -16,6 +16,11 @@ class CustomURLSearchParams { class DownloadQueue { constructor() { + // Constants read from the server config + this.MAX_RETRIES = 3; // Default max retries + this.RETRY_DELAY = 5; // Default retry delay in seconds + this.RETRY_DELAY_INCREASE = 5; // Default retry delay increase in seconds + this.downloadQueue = {}; // keyed by unique queueId this.currentConfig = {}; // Cache for current config @@ -277,13 +282,18 @@ class DownloadQueue { */ createQueueItem(item, type, prgFile, queueId) { const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...'; + + // Use display values if available, or fall back to standard fields + const displayTitle = item.name || 'Unknown'; + const displayType = type.charAt(0).toUpperCase() + type.slice(1); + const div = document.createElement('article'); div.className = 'queue-item'; div.setAttribute('aria-live', 'polite'); div.setAttribute('aria-atomic', 'true'); div.innerHTML = ` -
${item.name}
-
${type.charAt(0).toUpperCase() + type.slice(1)}
+
${displayTitle}
+
${displayType}
${defaultMessage}
- -
- `; - logElement.querySelector('.close-error-btn').addEventListener('click', () => { - if (entry.autoRetryInterval) { - clearInterval(entry.autoRetryInterval); - entry.autoRetryInterval = null; - } - this.cleanupEntry(queueId); - }); - logElement.querySelector('.retry-btn').addEventListener('click', async () => { - if (entry.autoRetryInterval) { - clearInterval(entry.autoRetryInterval); - entry.autoRetryInterval = null; - } - this.retryDownload(queueId, logElement); - }); - if (entry.requestUrl) { - const maxRetries = 10; - if (entry.retryCount < maxRetries) { - const autoRetryDelay = 300; // seconds - let secondsLeft = autoRetryDelay; - entry.autoRetryInterval = setInterval(() => { - secondsLeft--; - const errorMsgEl = logElement.querySelector('.error-message'); - if (errorMsgEl) { - errorMsgEl.textContent = `Error: ${progress.message || 'Unknown error'}. Retrying in ${secondsLeft} seconds... (attempt ${entry.retryCount + 1}/${maxRetries})`; - } - if (secondsLeft <= 0) { - clearInterval(entry.autoRetryInterval); - entry.autoRetryInterval = null; - this.retryDownload(queueId, logElement); - } - }, 1000); + + // Check if we're under the max retries threshold for auto-retry + const canRetry = entry.retryCount < this.MAX_RETRIES; + + if (canRetry) { + logElement.innerHTML = ` +
${this.getStatusMessage(progress)}
+
+ + +
+ `; + logElement.querySelector('.close-error-btn').addEventListener('click', () => { + if (entry.autoRetryInterval) { + clearInterval(entry.autoRetryInterval); + entry.autoRetryInterval = null; + } + this.cleanupEntry(queueId); + }); + logElement.querySelector('.retry-btn').addEventListener('click', async () => { + if (entry.autoRetryInterval) { + clearInterval(entry.autoRetryInterval); + entry.autoRetryInterval = null; + } + this.retryDownload(queueId, logElement); + }); + + // Implement auto-retry if we have the original request URL + if (entry.requestUrl) { + const maxRetries = this.MAX_RETRIES; + if (entry.retryCount < maxRetries) { + // Calculate the delay based on retry count (exponential backoff) + const baseDelay = this.RETRY_DELAY || 5; // seconds, use server's retry delay or default to 5 + const increase = this.RETRY_DELAY_INCREASE || 5; + const retryDelay = baseDelay + (entry.retryCount * increase); + + let secondsLeft = retryDelay; + entry.autoRetryInterval = setInterval(() => { + secondsLeft--; + const errorMsgEl = logElement.querySelector('.error-message'); + if (errorMsgEl) { + errorMsgEl.textContent = `Error: ${progress.message || 'Unknown error'}. Retrying in ${secondsLeft} seconds... (attempt ${entry.retryCount + 1}/${maxRetries})`; + } + if (secondsLeft <= 0) { + clearInterval(entry.autoRetryInterval); + entry.autoRetryInterval = null; + this.retryDownload(queueId, logElement); + } + }, 1000); + } } + } else { + // Cannot be retried - just show the error + logElement.innerHTML = ` +
${this.getStatusMessage(progress)}
+
+ +
+ `; + logElement.querySelector('.close-error-btn').addEventListener('click', () => { + this.cleanupEntry(queueId); + }); } return; + } else if (progress.status === 'interrupted') { + logElement.textContent = 'Download was interrupted'; + setTimeout(() => this.cleanupEntry(queueId), 5000); + } else if (progress.status === 'complete') { + logElement.textContent = 'Download completed successfully'; + // Hide the cancel button + const cancelBtn = entry.element.querySelector('.cancel-btn'); + if (cancelBtn) { + cancelBtn.style.display = 'none'; + } + // Add success styling + entry.element.classList.add('download-success'); + setTimeout(() => this.cleanupEntry(queueId), 5000); } else { logElement.textContent = this.getStatusMessage(progress); setTimeout(() => this.cleanupEntry(queueId), 5000); @@ -608,17 +685,36 @@ class DownloadQueue { async retryDownload(queueId, logElement) { const entry = this.downloadQueue[queueId]; if (!entry) return; + logElement.textContent = 'Retrying download...'; + + // If we don't have the request URL, we can't retry if (!entry.requestUrl) { logElement.textContent = 'Retry not available: missing original request information.'; return; } + try { + // Use the stored original request URL to create a new download const retryResponse = await fetch(entry.requestUrl); + if (!retryResponse.ok) { + throw new Error(`Server returned ${retryResponse.status}`); + } + const retryData = await retryResponse.json(); + if (retryData.prg_file) { + // If the old PRG file exists, we should delete it const oldPrgFile = entry.prgFile; - await fetch(`/api/prgs/delete/${oldPrgFile}`, { method: 'DELETE' }); + if (oldPrgFile) { + try { + await fetch(`/api/prgs/delete/${oldPrgFile}`, { method: 'DELETE' }); + } catch (deleteError) { + console.error('Error deleting old PRG file:', deleteError); + } + } + + // Update the entry with the new PRG file const logEl = entry.element.querySelector('.log'); logEl.id = `log-${entry.uniqueId}-${retryData.prg_file}`; entry.prgFile = retryData.prg_file; @@ -627,60 +723,27 @@ class DownloadQueue { entry.lastUpdated = Date.now(); entry.retryCount = (entry.retryCount || 0) + 1; logEl.textContent = 'Retry initiated...'; + + // Start monitoring the new PRG file this.startEntryMonitoring(queueId); } else { logElement.textContent = 'Retry failed: invalid response from server'; } } catch (error) { + console.error('Retry error:', error); logElement.textContent = 'Retry failed: ' + error.message; } } - /** - * Builds common URL parameters for download API requests. - */ - _buildCommonParams(url, service, config) { - const params = new CustomURLSearchParams(); - params.append('service', service); - params.append('url', url); - - if (service === 'spotify') { - if (config.fallback) { - params.append('main', config.deezer); - params.append('fallback', config.spotify); - params.append('quality', config.deezerQuality); - params.append('fall_quality', config.spotifyQuality); - } else { - params.append('main', config.spotify); - params.append('quality', config.spotifyQuality); - } - } else { - params.append('main', config.deezer); - params.append('quality', config.deezerQuality); - } - - if (config.realTime) { - params.append('real_time', 'true'); - } - - if (config.customTrackFormat) { - params.append('custom_track_format', config.customTrackFormat); - } - - if (config.customDirFormat) { - params.append('custom_dir_format', config.customDirFormat); - } - - return params; - } - async startTrackDownload(url, item) { await this.loadConfig(); const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer'; - const params = this._buildCommonParams(url, service, this.currentConfig); - params.append('name', item.name || ''); - params.append('artist', item.artist || ''); - const apiUrl = `/api/track/download?${params.toString()}`; + + // Use minimal parameters in the URL, letting server use config for defaults + const apiUrl = `/api/track/download?service=${service}&url=${encodeURIComponent(url)}` + + (item.name ? `&name=${encodeURIComponent(item.name)}` : '') + + (item.artist ? `&artist=${encodeURIComponent(item.artist)}` : ''); + try { const response = await fetch(apiUrl); if (!response.ok) throw new Error('Network error'); @@ -695,12 +758,15 @@ class DownloadQueue { async startPlaylistDownload(url, item) { await this.loadConfig(); const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer'; - const params = this._buildCommonParams(url, service, this.currentConfig); - params.append('name', item.name || ''); - params.append('artist', item.artist || ''); - const apiUrl = `/api/playlist/download?${params.toString()}`; + + // Use minimal parameters in the URL, letting server use config for defaults + const apiUrl = `/api/playlist/download?service=${service}&url=${encodeURIComponent(url)}` + + (item.name ? `&name=${encodeURIComponent(item.name)}` : '') + + (item.artist ? `&artist=${encodeURIComponent(item.artist)}` : ''); + try { const response = await fetch(apiUrl); + if (!response.ok) throw new Error('Network error'); const data = await response.json(); this.addDownload(item, 'playlist', data.prg_file, apiUrl); } catch (error) { @@ -709,14 +775,16 @@ class DownloadQueue { } } - async startArtistDownload(url, item, albumType = 'album,single,compilation,appears_on') { + async startArtistDownload(url, item, albumType = 'album,single,compilation') { await this.loadConfig(); const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer'; - const params = this._buildCommonParams(url, service, this.currentConfig); - params.append('album_type', albumType); - params.append('name', item.name || ''); - params.append('artist', item.artist || ''); - const apiUrl = `/api/artist/download?${params.toString()}`; + + // Use minimal parameters in the URL, letting server use config for defaults + const apiUrl = `/api/artist/download?service=${service}&url=${encodeURIComponent(url)}` + + `&album_type=${albumType}` + + (item.name ? `&name=${encodeURIComponent(item.name)}` : '') + + (item.artist ? `&artist=${encodeURIComponent(item.artist)}` : ''); + try { const response = await fetch(apiUrl); if (!response.ok) throw new Error('Network error'); @@ -737,12 +805,15 @@ class DownloadQueue { async startAlbumDownload(url, item) { await this.loadConfig(); const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer'; - const params = this._buildCommonParams(url, service, this.currentConfig); - params.append('name', item.name || ''); - params.append('artist', item.artist || ''); - const apiUrl = `/api/album/download?${params.toString()}`; + + // Use minimal parameters in the URL, letting server use config for defaults + const apiUrl = `/api/album/download?service=${service}&url=${encodeURIComponent(url)}` + + (item.name ? `&name=${encodeURIComponent(item.name)}` : '') + + (item.artist ? `&artist=${encodeURIComponent(item.artist)}` : ''); + try { const response = await fetch(apiUrl); + if (!response.ok) throw new Error('Network error'); const data = await response.json(); this.addDownload(item, 'album', data.prg_file, apiUrl); } catch (error) { @@ -772,16 +843,81 @@ class DownloadQueue { const prgResponse = await fetch(`/api/prgs/${prgFile}`); if (!prgResponse.ok) continue; const prgData = await prgResponse.json(); + + // Skip prg files that are marked as cancelled or completed + if (prgData.last_line && + (prgData.last_line.status === 'cancel' || + prgData.last_line.status === 'complete')) { + // Delete old completed or cancelled PRG files + try { + await fetch(`/api/prgs/delete/${prgFile}`, { method: 'DELETE' }); + console.log(`Cleaned up old PRG file: ${prgFile}`); + } catch (error) { + console.error(`Failed to delete completed/cancelled PRG file ${prgFile}:`, error); + } + continue; + } + + // Use the enhanced original request info from the first line + const originalRequest = prgData.original_request || {}; + + // Use the explicit display fields if available, or fall back to other fields const dummyItem = { - name: prgData.original_request && prgData.original_request.name ? prgData.original_request.name : prgFile, - artist: prgData.original_request && prgData.original_request.artist ? prgData.original_request.artist : '', - type: prgData.original_request && prgData.original_request.type ? prgData.original_request.type : 'unknown' + name: prgData.display_title || originalRequest.display_title || originalRequest.name || prgFile, + artist: prgData.display_artist || originalRequest.display_artist || originalRequest.artist || '', + type: prgData.display_type || originalRequest.display_type || originalRequest.type || 'unknown', + service: originalRequest.service || '', + url: originalRequest.url || '', + endpoint: originalRequest.endpoint || '', + download_type: originalRequest.download_type || '' }; - this.addDownload(dummyItem, dummyItem.type, prgFile); + + // Check if this is a retry file and get the retry count + let retryCount = 0; + if (prgFile.includes('_retry')) { + const retryMatch = prgFile.match(/_retry(\d+)/); + if (retryMatch && retryMatch[1]) { + retryCount = parseInt(retryMatch[1], 10); + } else if (prgData.last_line && prgData.last_line.retry_count) { + retryCount = prgData.last_line.retry_count; + } + } else if (prgData.last_line && prgData.last_line.retry_count) { + retryCount = prgData.last_line.retry_count; + } + + // Build a potential requestUrl from the original information + let requestUrl = null; + if (dummyItem.endpoint && dummyItem.url) { + const params = new CustomURLSearchParams(); + params.append('service', dummyItem.service); + params.append('url', dummyItem.url); + + if (dummyItem.name) params.append('name', dummyItem.name); + if (dummyItem.artist) params.append('artist', dummyItem.artist); + + // Add any other parameters from the original request + for (const [key, value] of Object.entries(originalRequest)) { + if (!['service', 'url', 'name', 'artist', 'type', 'endpoint', 'download_type', + 'display_title', 'display_type', 'display_artist'].includes(key)) { + params.append(key, value); + } + } + + requestUrl = `${dummyItem.endpoint}?${params.toString()}`; + } + + // Add to download queue + const queueId = this.generateQueueId(); + const entry = this.createQueueEntry(dummyItem, dummyItem.type, prgFile, queueId, requestUrl); + entry.retryCount = retryCount; + this.downloadQueue[queueId] = entry; } catch (error) { console.error("Error fetching details for", prgFile, error); } } + + // After adding all entries, update the queue + this.updateQueueOrder(); } catch (error) { console.error("Error loading existing PRG files:", error); } @@ -792,6 +928,19 @@ class DownloadQueue { const response = await fetch('/api/config'); if (!response.ok) throw new Error('Failed to fetch config'); this.currentConfig = await response.json(); + + // Update our retry constants from the server config + if (this.currentConfig.maxRetries !== undefined) { + this.MAX_RETRIES = this.currentConfig.maxRetries; + } + if (this.currentConfig.retryDelaySeconds !== undefined) { + this.RETRY_DELAY = this.currentConfig.retryDelaySeconds; + } + if (this.currentConfig.retry_delay_increase !== undefined) { + this.RETRY_DELAY_INCREASE = this.currentConfig.retry_delay_increase; + } + + console.log(`Loaded retry settings from config: max=${this.MAX_RETRIES}, delay=${this.RETRY_DELAY}, increase=${this.RETRY_DELAY_INCREASE}`); } catch (error) { console.error('Error loading config:', error); this.currentConfig = {}; diff --git a/static/js/track.js b/static/js/track.js index ebd25de..38df3cf 100644 --- a/static/js/track.js +++ b/static/js/track.js @@ -11,18 +11,8 @@ document.addEventListener('DOMContentLoaded', () => { return; } - // Fetch the config to get active Spotify account first - fetch('/api/config') - .then(response => { - if (!response.ok) throw new Error('Failed to fetch config'); - return response.json(); - }) - .then(config => { - const mainAccount = config.spotify || ''; - - // Then fetch track info with the main parameter - return fetch(`/api/track/info?id=${encodeURIComponent(trackId)}&main=${mainAccount}`); - }) + // Fetch track info directly + fetch(`/api/track/info?id=${encodeURIComponent(trackId)}`) .then(response => { if (!response.ok) throw new Error('Network response was not ok'); return response.json(); @@ -52,25 +42,25 @@ function renderTrack(track) { // Update track information fields. document.getElementById('track-name').innerHTML = - `${track.name}`; + `${track.name || 'Unknown Track'}`; document.getElementById('track-artist').innerHTML = - `By ${track.artists.map(a => - `${a.name}` - ).join(', ')}`; + `By ${track.artists?.map(a => + `${a?.name || 'Unknown Artist'}` + ).join(', ') || 'Unknown Artist'}`; document.getElementById('track-album').innerHTML = - `Album: ${track.album.name} (${track.album.album_type})`; + `Album: ${track.album?.name || 'Unknown Album'} (${track.album?.album_type || 'album'})`; document.getElementById('track-duration').textContent = - `Duration: ${msToTime(track.duration_ms)}`; + `Duration: ${msToTime(track.duration_ms || 0)}`; document.getElementById('track-explicit').textContent = track.explicit ? 'Explicit' : 'Clean'; - const imageUrl = (track.album.images && track.album.images[0]) + const imageUrl = (track.album?.images && track.album.images[0]) ? track.album.images[0].url - : 'placeholder.jpg'; + : '/static/images/placeholder.jpg'; document.getElementById('track-album-image').src = imageUrl; // --- Insert Home Button (if not already present) --- @@ -81,7 +71,10 @@ function renderTrack(track) { homeButton.className = 'home-btn'; homeButton.innerHTML = `Home`; // Prepend the home button into the header. - document.getElementById('track-header').insertBefore(homeButton, document.getElementById('track-header').firstChild); + const trackHeader = document.getElementById('track-header'); + if (trackHeader) { + trackHeader.insertBefore(homeButton, trackHeader.firstChild); + } } homeButton.addEventListener('click', () => { window.location.href = window.location.origin; @@ -93,28 +86,41 @@ function renderTrack(track) { // Remove the parent container (#actions) if needed. const actionsContainer = document.getElementById('actions'); if (actionsContainer) { - actionsContainer.parentNode.removeChild(actionsContainer); + actionsContainer.parentNode?.removeChild(actionsContainer); } // Set the inner HTML to use the download.svg icon. downloadBtn.innerHTML = `Download`; // Append the download button to the track header so it appears at the right. - document.getElementById('track-header').appendChild(downloadBtn); + const trackHeader = document.getElementById('track-header'); + if (trackHeader) { + trackHeader.appendChild(downloadBtn); + } } - downloadBtn.addEventListener('click', () => { - downloadBtn.disabled = true; - downloadBtn.innerHTML = `Queueing...`; - - downloadQueue.startTrackDownload(track.external_urls.spotify, { name: track.name }) - .then(() => { - downloadBtn.innerHTML = `Queued!`; - }) - .catch(err => { - showError('Failed to queue track download: ' + err.message); + if (downloadBtn) { + downloadBtn.addEventListener('click', () => { + downloadBtn.disabled = true; + downloadBtn.innerHTML = `Queueing...`; + + const trackUrl = track.external_urls?.spotify || ''; + if (!trackUrl) { + showError('Missing track URL'); downloadBtn.disabled = false; downloadBtn.innerHTML = `Download`; - }); - }); + return; + } + + downloadQueue.startTrackDownload(trackUrl, { name: track.name || 'Unknown Track' }) + .then(() => { + downloadBtn.innerHTML = `Queued!`; + }) + .catch(err => { + showError('Failed to queue track download: ' + (err?.message || 'Unknown error')); + downloadBtn.disabled = false; + downloadBtn.innerHTML = `Download`; + }); + }); + } // Reveal the header now that track info is loaded. document.getElementById('track-header').classList.remove('hidden'); @@ -124,6 +130,8 @@ function renderTrack(track) { * Converts milliseconds to minutes:seconds. */ function msToTime(duration) { + if (!duration || isNaN(duration)) return '0:00'; + const minutes = Math.floor(duration / 60000); const seconds = Math.floor((duration % 60000) / 1000); return `${minutes}:${seconds.toString().padStart(2, '0')}`; @@ -135,49 +143,30 @@ function msToTime(duration) { function showError(message) { const errorEl = document.getElementById('error'); if (errorEl) { - errorEl.textContent = message; + errorEl.textContent = message || 'An error occurred'; errorEl.classList.remove('hidden'); } } /** - * Starts the download process by building the API URL, - * fetching download details, and then adding the download to the queue. + * Starts the download process by building a minimal API URL with only the necessary parameters, + * since the server will use config defaults for others. */ async function startDownload(url, type, item) { - const config = JSON.parse(localStorage.getItem('activeConfig')) || {}; - const { - fallback = false, - spotify = '', - deezer = '', - spotifyQuality = 'NORMAL', - deezerQuality = 'MP3_128', - realTime = false, - customTrackFormat = '', - customDirFormat = '' - } = config; - + if (!url || !type) { + showError('Missing URL or type for download'); + return; + } + const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer'; let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`; - if (fallback && service === 'spotify') { - apiUrl += `&main=${deezer}&fallback=${spotify}`; - apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`; - } else { - const mainAccount = service === 'spotify' ? spotify : deezer; - apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`; + // Add name and artist if available for better progress display + if (item.name) { + apiUrl += `&name=${encodeURIComponent(item.name)}`; } - - if (realTime) { - apiUrl += '&real_time=true'; - } - - // Append custom formatting parameters if they are set. - if (customTrackFormat) { - apiUrl += `&custom_track_format=${encodeURIComponent(customTrackFormat)}`; - } - if (customDirFormat) { - apiUrl += `&custom_dir_format=${encodeURIComponent(customDirFormat)}`; + if (item.artist) { + apiUrl += `&artist=${encodeURIComponent(item.artist)}`; } try { @@ -185,7 +174,7 @@ async function startDownload(url, type, item) { const data = await response.json(); downloadQueue.addDownload(item, type, data.prg_file); } catch (error) { - showError('Download failed: ' + error.message); + showError('Download failed: ' + (error?.message || 'Unknown error')); throw error; } } diff --git a/templates/album.html b/templates/album.html index 1ecb1b0..ed40370 100644 --- a/templates/album.html +++ b/templates/album.html @@ -11,7 +11,7 @@