From 5b91339b47b6a347be8af0730ff023eacbdaadf6 Mon Sep 17 00:00:00 2001 From: "cool.gitter.choco" Date: Mon, 10 Feb 2025 10:43:51 -0600 Subject: [PATCH] improved queue handling and artist downloading --- routes/artist.py | 244 +++++-------------- routes/utils/artist.py | 147 ++++++------ routes/utils/queue.py | 11 +- static/css/queue/queue.css | 183 +++++++------- static/js/artist.js | 91 ++++--- static/js/main.js | 51 ++-- static/js/queue.js | 471 ++++++++++++++++++++----------------- 7 files changed, 576 insertions(+), 622 deletions(-) diff --git a/routes/artist.py b/routes/artist.py index 0376ea5..9cf45f6 100644 --- a/routes/artist.py +++ b/routes/artist.py @@ -8,114 +8,36 @@ import json import os import random import string -import sys import traceback -from multiprocessing import Process artist_bp = Blueprint('artist', __name__) -# Global dictionary to keep track of running download processes. -download_processes = {} +def log_json(message_dict): + print(json.dumps(message_dict)) -def generate_random_filename(length=6): - chars = string.ascii_lowercase + string.digits - return ''.join(random.choice(chars) for _ in range(length)) + '.artist.prg' - -class FlushingFileWrapper: - def __init__(self, file): - self.file = file - - def write(self, text): - # Only write lines that start with '{' - for line in text.split('\n'): - line = line.strip() - if line and line.startswith('{'): - try: - obj = json.loads(line) - # Skip writing if the JSON object has type "track" - if obj.get("type") == "track": - continue - except ValueError: - # If not valid JSON, write the line as is. - pass - self.file.write(line + '\n') - self.file.flush() - - def flush(self): - self.file.flush() - -def download_artist_task(service, artist_url, main, fallback, quality, fall_quality, real_time, - album_type, prg_path, orig_request, custom_dir_format, custom_track_format): - """ - This function wraps the call to download_artist_albums, writes the original - request data to the progress file, and then writes JSON status updates. - """ - try: - from routes.utils.artist import download_artist_albums - with open(prg_path, 'w') as f: - flushing_file = FlushingFileWrapper(f) - original_stdout = sys.stdout - sys.stdout = flushing_file # Redirect stdout to our flushing file wrapper - - # Write the original request data to the progress file. - try: - flushing_file.write(json.dumps({"original_request": orig_request}) + "\n") - except Exception as e: - flushing_file.write(json.dumps({ - "status": "error", - "message": f"Failed to write original request data: {str(e)}" - }) + "\n") - - try: - download_artist_albums( - service=service, - artist_url=artist_url, - main=main, - fallback=fallback, - quality=quality, - fall_quality=fall_quality, - real_time=real_time, - album_type=album_type, - custom_dir_format=custom_dir_format, - custom_track_format=custom_track_format - ) - flushing_file.write(json.dumps({"status": "complete"}) + "\n") - except Exception as e: - error_data = json.dumps({ - "status": "error", - "message": str(e), - "traceback": traceback.format_exc() - }) - flushing_file.write(error_data + "\n") - finally: - sys.stdout = original_stdout # Restore stdout - except Exception as e: - with open(prg_path, 'w') as f: - error_data = json.dumps({ - "status": "error", - "message": str(e), - "traceback": traceback.format_exc() - }) - f.write(error_data + "\n") @artist_bp.route('/download', methods=['GET']) def handle_artist_download(): """ - Starts the artist album download process. + Enqueues album download tasks for the given artist using the new artist module. Expected query parameters: - - artist_url: string (e.g., a Spotify artist URL) - - service: string (e.g., "deezer" or "spotify") - - main: string (e.g., "MX") - - fallback: string (optional, e.g., "JP") + - 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); one or more of "album", "single", "appears_on", "compilation" (if multiple, comma-separated) + - 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. """ service = request.args.get('service') - artist_url = request.args.get('artist_url') + url = request.args.get('url') main = request.args.get('main') fallback = request.args.get('fallback') quality = request.args.get('quality') @@ -125,17 +47,17 @@ def handle_artist_download(): 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%") + 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%") - # Sanitize main and fallback to prevent directory traversal + # Sanitize main and fallback to prevent directory traversal. if main: main = os.path.basename(main) if fallback: fallback = os.path.basename(fallback) # Check for required parameters. - if not all([service, artist_url, main, quality, album_type]): + if not all([service, url, main, quality, album_type]): return Response( json.dumps({"error": "Missing parameters"}), status=400, @@ -146,7 +68,7 @@ def handle_artist_download(): try: if service == 'spotify': if fallback: - # When using Spotify as the main service with a fallback, assume main credentials for Deezer and fallback for Spotify. + # When a fallback is provided, validate both Deezer and Spotify fallback credentials. deezer_creds_path = os.path.abspath(os.path.join('./creds/deezer', main, 'credentials.json')) if not os.path.isfile(deezer_creds_path): return Response( @@ -162,7 +84,6 @@ def handle_artist_download(): mimetype='application/json' ) else: - # Validate Spotify main credentials. spotify_creds_path = os.path.abspath(os.path.join('./creds/spotify', main, 'credentials.json')) if not os.path.isfile(spotify_creds_path): return Response( @@ -171,7 +92,6 @@ def handle_artist_download(): mimetype='application/json' ) elif service == 'deezer': - # Validate Deezer main credentials. deezer_creds_path = os.path.abspath(os.path.join('./creds/deezer', main, 'credentials.json')) if not os.path.isfile(deezer_creds_path): return Response( @@ -192,92 +112,61 @@ def handle_artist_download(): mimetype='application/json' ) - # Create a random filename for the progress file. - filename = generate_random_filename() - prg_dir = './prgs' - os.makedirs(prg_dir, exist_ok=True) - prg_path = os.path.join(prg_dir, filename) - - # Capture the original request parameters as a dictionary. - orig_request = request.args.to_dict() - - # Create and start the download process. - process = Process( - target=download_artist_task, - args=( - service, - artist_url, - main, - fallback, - quality, - fall_quality, - real_time, - album_type, - prg_path, - orig_request, - custom_dir_format, - custom_track_format + try: + # Import and call the updated download_artist_albums() function. + from routes.utils.artist import download_artist_albums + album_prg_files = download_artist_albums( + service=service, + url=url, + main=main, + fallback=fallback, + quality=quality, + fall_quality=fall_quality, + real_time=real_time, + album_type=album_type, + custom_dir_format=custom_dir_format, + custom_track_format=custom_track_format + ) + # Return the list of album PRG filenames. + return Response( + json.dumps({ + "status": "complete", + "album_prg_files": album_prg_files, + "message": "Artist download completed – album tasks have been queued." + }), + status=202, + mimetype='application/json' + ) + except Exception as e: + return Response( + json.dumps({ + "status": "error", + "message": str(e), + "traceback": traceback.format_exc() + }), + status=500, + mimetype='application/json' ) - ) - process.start() - download_processes[filename] = process - return Response( - json.dumps({"prg_file": filename}), - status=202, - mimetype='application/json' - ) @artist_bp.route('/download/cancel', methods=['GET']) def cancel_artist_download(): """ - Cancel a running artist download process by its prg file name. + Cancelling an artist download is not supported since the endpoint only enqueues album tasks. + (Cancellation for individual album tasks can be implemented via the queue manager.) """ - prg_file = request.args.get('prg_file') - if not prg_file: - return Response( - json.dumps({"error": "Missing process id (prg_file) parameter"}), - status=400, - mimetype='application/json' - ) + return Response( + json.dumps({"error": "Artist download cancellation is not supported."}), + status=400, + mimetype='application/json' + ) - process = download_processes.get(prg_file) - prg_dir = './prgs' - prg_path = os.path.join(prg_dir, prg_file) - if process and process.is_alive(): - process.terminate() - process.join() # Wait for termination - del download_processes[prg_file] - - try: - with open(prg_path, 'a') as f: - f.write(json.dumps({"status": "cancel"}) + "\n") - except Exception as e: - return Response( - json.dumps({"error": f"Failed to write cancel status to file: {str(e)}"}), - status=500, - mimetype='application/json' - ) - - return Response( - json.dumps({"status": "cancel"}), - status=200, - mimetype='application/json' - ) - else: - return Response( - json.dumps({"error": "Process not found or already terminated"}), - status=404, - mimetype='application/json' - ) - -# NEW ENDPOINT: Get Artist Information @artist_bp.route('/info', methods=['GET']) def get_artist_info(): """ - Retrieve Spotify artist metadata given a Spotify ID. - Expects a query parameter 'id' that contains the Spotify artist ID. + Retrieves Spotify artist metadata given a Spotify artist ID. + Expects a query parameter 'id' with the Spotify artist ID. """ spotify_id = request.args.get('id') if not spotify_id: @@ -288,9 +177,7 @@ def get_artist_info(): ) try: - # Import the get_spotify_info function from the utility module. from routes.utils.get_info import get_spotify_info - # Call the function with the artist type. artist_info = get_spotify_info(spotify_id, "artist") return Response( json.dumps(artist_info), @@ -298,12 +185,11 @@ def get_artist_info(): mimetype='application/json' ) except Exception as e: - error_data = { - "error": str(e), - "traceback": traceback.format_exc() - } return Response( - json.dumps(error_data), + json.dumps({ + "error": str(e), + "traceback": traceback.format_exc() + }), status=500, mimetype='application/json' - ) \ No newline at end of file + ) diff --git a/routes/utils/artist.py b/routes/utils/artist.py index 507bd73..b441584 100644 --- a/routes/utils/artist.py +++ b/routes/utils/artist.py @@ -3,7 +3,8 @@ import traceback from deezspot.easy_spoty import Spo from deezspot.libutils.utils import get_ids, link_is_valid -from routes.utils.album import download_album # Assumes album.py is in routes/utils/ +from routes.utils.queue import download_queue_manager # Global download queue manager + def log_json(message_dict): """Helper function to output a JSON-formatted log message.""" @@ -11,116 +12,112 @@ def log_json(message_dict): def get_artist_discography(url, album_type='album,single,compilation,appears_on'): + """ + Validate the URL, extract the artist ID, and retrieve the discography. + """ if not url: - message = "No artist URL provided." - log_json({"status": "error", "message": message}) - raise ValueError(message) + log_json({"status": "error", "message": "No artist URL provided."}) + raise ValueError("No artist URL provided.") + + # This will raise an exception if the link is invalid. + link_is_valid(link=url) try: - # Validate the URL (this function should raise an error if invalid). - link_is_valid(link=url) - except Exception as validation_error: - message = f"Link validation failed: {validation_error}" - log_json({"status": "error", "message": message}) - raise ValueError(message) - - try: - # Extract the artist ID from the URL. artist_id = get_ids(url) except Exception as id_error: - message = f"Failed to extract artist ID from URL: {id_error}" - log_json({"status": "error", "message": message}) - raise ValueError(message) + msg = f"Failed to extract artist ID from URL: {id_error}" + log_json({"status": "error", "message": msg}) + raise ValueError(msg) try: - # Retrieve the discography using the artist ID. discography = Spo.get_artist(artist_id, album_type=album_type) return discography except Exception as fetch_error: - message = f"An error occurred while fetching the discography: {fetch_error}" - log_json({"status": "error", "message": message}) + msg = f"An error occurred while fetching the discography: {fetch_error}" + log_json({"status": "error", "message": msg}) raise -def download_artist_albums(service, artist_url, main, fallback=None, quality=None, - fall_quality=None, real_time=False, album_type='album,single,compilation,appears_on', +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%"): + """ + 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 + creates a PRG file for each album download. This function returns a list of those + album PRG filenames. + """ try: - discography = get_artist_discography(artist_url, album_type=album_type) + discography = get_artist_discography(url, album_type=album_type) except Exception as e: log_json({"status": "error", "message": f"Error retrieving artist discography: {e}"}) raise + albums = discography.get('items', []) - # Extract artist name from the first album's artists as fallback. - artist_name = artist_url - if albums: - first_album = albums[0] - artists = first_album.get('artists', []) - if artists: - artist_name = artists[0].get('name', artist_url) - if not albums: - log_json({ - "status": "done", - "type": "artist", - "artist": artist_name, - "album_type": album_type, - "message": "No albums found for the artist." - }) - return + log_json({"status": "done", "message": "No albums found for the artist."}) + return [] - log_json({ - "status": "initializing", - "type": "artist", - "artist": artist_name, - "total_albums": len(albums), - "album_type": album_type - }) + prg_files = [] for album in albums: try: album_url = album.get('external_urls', {}).get('spotify') - album_name = album.get('name', 'Unknown Album') - # Extract artist names if available. - artists = [] - if "artists" in album: - artists = [artist.get("name", "Unknown") for artist in album["artists"]] if not album_url: log_json({ "status": "warning", - "type": "album", - "album": album_name, - "artist": artists, - "message": "No Spotify URL found; skipping." + "message": f"No Spotify URL found for album '{album.get('name', 'Unknown Album')}'; skipping." }) continue - download_album( - service=service, - url=album_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 - ) + album_name = album.get('name', 'Unknown Album') + artists = album.get('artists', []) + # Extract artist names or use "Unknown" as a fallback. + artists = [artist.get("name", "Unknown") for artist in artists] + + # Prepare the download task dictionary. + task = { + "download_type": "album", + "service": service, + "url": album_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, + # Extra info for logging in the PRG file. + "name": album_name, + "type": "album", + "artist": artists, + "orig_request": { + "type": "album", + "name": album_name, + "artist": artists + } + } + + # Add the task to the global download queue. + # The queue manager creates the album's PRG file and returns its filename. + prg_filename = download_queue_manager.add_task(task) + prg_files.append(prg_filename) + + log_json({ + "status": "queued", + "album": album_name, + "artist": artists, + "prg_file": prg_filename, + "message": "Album queued for download." + }) except Exception as album_error: log_json({ "status": "error", - "type": "album", - "album": album.get('name', 'Unknown'), - "error": str(album_error) + "message": f"Error processing album '{album.get('name', 'Unknown')}': {album_error}" }) traceback.print_exc() - # When everything has been processed, print the final status. - log_json({ - "status": "done", - "type": "artist", - "artist": artist_name, - "album_type": album_type - }) \ No newline at end of file + return prg_files diff --git a/routes/utils/queue.py b/routes/utils/queue.py index 3ea2873..20d51d6 100644 --- a/routes/utils/queue.py +++ b/routes/utils/queue.py @@ -161,14 +161,17 @@ class DownloadQueueManager: Returns the generated prg filename so that the caller can later check the status or request cancellation. """ - prg_filename = generate_random_filename() - prg_path = os.path.join(self.prg_dir, prg_filename) - task['prg_path'] = prg_path - + # Determine the download type, defaulting to 'unknown' if not provided. + download_type = task.get("download_type", "unknown") # Compute the overall position in the queue: # position = (number of running tasks) + (number of pending tasks) + 1. position = len(self.running_downloads) + self.pending_tasks.qsize() + 1 + # Generate the prg filename based on the download type and queue position. + prg_filename = f"{download_type}_{position}.prg" + prg_path = os.path.join(self.prg_dir, prg_filename) + task['prg_path'] = prg_path + # Create and immediately write the initial entries to the .prg file. try: with open(prg_path, 'w') as f: diff --git a/static/css/queue/queue.css b/static/css/queue/queue.css index 2c8d2d9..66ef27f 100644 --- a/static/css/queue/queue.css +++ b/static/css/queue/queue.css @@ -27,7 +27,7 @@ } /* Header inside the queue sidebar */ -.queue-header { +.sidebar-header { display: flex; justify-content: space-between; align-items: center; @@ -36,35 +36,53 @@ margin-bottom: 20px; } -.queue-title { +.sidebar-header h2 { font-size: 1.25rem; font-weight: 600; color: #fff; + margin: 0; +} + +.header-actions { + display: flex; + gap: 10px; + align-items: center; +} + +/* Cancel all button styling */ +#cancelAllBtn { + background: #ff5555; + border: none; + color: #fff; + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + transition: background 0.3s ease; + font-size: 14px; +} + +#cancelAllBtn:hover { + background: #ff7777; } /* Close button for the queue sidebar */ -.queue-close { +.close-btn { background: #2a2a2a; + border: none; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; - transition: background-color 0.3s ease; - cursor: pointer; -} - -.queue-close:hover { - background-color: #333; -} - -/* X Icon style for the close button */ -.queue-close::before { - content: "×"; + color: #ffffff; font-size: 20px; - color: #fff; - line-height: 32px; /* Center the icon vertically within the button */ + cursor: pointer; + transition: background-color 0.3s ease; +} + +.close-btn:hover { + background-color: #333; } /* Container for all queue items */ @@ -72,7 +90,6 @@ /* Allow the container to fill all available space in the sidebar */ flex: 1; overflow-y: auto; - /* Removed max-height: 60vh; */ } /* Each download queue item */ @@ -192,6 +209,7 @@ to { transform: rotate(360deg); } } +/* Cancel button inside each queue item */ .cancel-btn { background: none; border: none; @@ -205,8 +223,8 @@ } .cancel-btn img { - width: 16px; /* Reduced from 24px */ - height: 16px; /* Reduced from 24px */ + width: 16px; + height: 16px; filter: invert(1); transition: transform 0.3s ease; } @@ -219,71 +237,28 @@ transform: scale(0.9); } -/* Close button for the download queue sidebar */ -.close-btn { - background: #2a2a2a; +/* ------------------------------- */ +/* FOOTER & "SHOW MORE" BUTTON */ +/* ------------------------------- */ + +#queueFooter { + text-align: center; + padding-top: 10px; +} + +#queueFooter button { + background: #1DB954; border: none; - border-radius: 50%; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - color: #ffffff; - font-size: 20px; + padding: 8px 16px; + border-radius: 4px; + color: #fff; cursor: pointer; - transition: background-color 0.3s ease; + transition: background 0.3s ease; + font-size: 14px; } -.close-btn:hover { - background-color: #333; -} - -/* ------------------------------- */ -/* MOBILE RESPONSIVE ADJUSTMENTS */ -/* ------------------------------- */ -@media (max-width: 600px) { - /* Make the sidebar full width on mobile */ - #downloadQueue { - width: 100%; - right: -100%; /* Off-screen fully */ - padding: 15px; - } - - /* When active, the sidebar slides into view from full width */ - #downloadQueue.active { - right: 0; - } - - /* Adjust header and title for smaller screens */ - .queue-header { - flex-direction: column; - align-items: flex-start; - } - - .queue-title { - font-size: 1.1rem; - } - - /* Reduce the size of the close buttons */ - .queue-close, - .close-btn { - width: 28px; - height: 28px; - font-size: 18px; - } - - /* Adjust queue items padding */ - .queue-item { - padding: 12px; - margin-bottom: 12px; - } - - /* Ensure text remains legible on smaller screens */ - .queue-item .log, - .queue-item .type { - font-size: 12px; - } +#queueFooter button:hover { + background: #17a448; } /* -------------------------- */ @@ -327,7 +302,6 @@ padding: 0; } -/* Hover state for the Close (X) button */ .close-error-btn:hover { background: #ff7777; } @@ -340,7 +314,52 @@ font-weight: bold; } -/* Hover state for the Retry button */ .retry-btn:hover { background: #17a448; } + +/* ------------------------------- */ +/* MOBILE RESPONSIVE ADJUSTMENTS */ +/* ------------------------------- */ +@media (max-width: 600px) { + /* Make the sidebar full width on mobile */ + #downloadQueue { + width: 100%; + right: -100%; /* Off-screen fully */ + padding: 15px; + } + + /* When active, the sidebar slides into view from full width */ + #downloadQueue.active { + right: 0; + } + + /* Adjust header and title for smaller screens */ + .sidebar-header { + flex-direction: column; + align-items: flex-start; + } + + .sidebar-header h2 { + font-size: 1.1rem; + } + + /* Reduce the size of the close buttons */ + .close-btn { + width: 28px; + height: 28px; + font-size: 18px; + } + + /* Adjust queue items padding */ + .queue-item { + padding: 12px; + margin-bottom: 12px; + } + + /* Ensure text remains legible on smaller screens */ + .queue-item .log, + .queue-item .type { + font-size: 12px; + } +} diff --git a/static/js/artist.js b/static/js/artist.js index 98e3336..6e9a8e9 100644 --- a/static/js/artist.js +++ b/static/js/artist.js @@ -40,6 +40,9 @@ function renderArtist(artistData, artistId) { document.getElementById('artist-stats').textContent = `${artistData.total} albums`; document.getElementById('artist-image').src = artistImage; + // Define the artist URL (used by both full-discography and group downloads) + const artistUrl = `https://open.spotify.com/artist/${artistId}`; + // Home Button let homeButton = document.getElementById('homeButton'); if (!homeButton) { @@ -51,7 +54,7 @@ function renderArtist(artistData, artistId) { } homeButton.addEventListener('click', () => window.location.href = window.location.origin); - // Download Whole Artist Button + // Download Whole Artist Button using the new artist API endpoint let downloadArtistBtn = document.getElementById('downloadArtistBtn'); if (!downloadArtistBtn) { downloadArtistBtn = document.createElement('button'); @@ -62,14 +65,28 @@ function renderArtist(artistData, artistId) { } downloadArtistBtn.addEventListener('click', () => { + // Optionally remove other download buttons from individual albums. document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove()); downloadArtistBtn.disabled = true; downloadArtistBtn.textContent = 'Queueing...'; - queueAllAlbums(artistData.items, downloadArtistBtn); + // Queue the entire discography (albums, singles, compilations, and appears_on) + downloadQueue.startArtistDownload( + artistUrl, + { name: artistName, artist: artistName }, + 'album,single,compilation,appears_on' + ) + .then(() => { + downloadArtistBtn.textContent = 'Artist queued'; + }) + .catch(err => { + downloadArtistBtn.textContent = 'Download All Discography'; + downloadArtistBtn.disabled = false; + showError('Failed to queue artist download: ' + err.message); + }); }); - // Group albums by type + // Group albums by type (album, single, compilation, etc.) const albumGroups = artistData.items.reduce((groups, album) => { const type = album.album_type.toLowerCase(); if (!groups[type]) groups[type] = []; @@ -84,7 +101,7 @@ function renderArtist(artistData, artistId) { for (const [groupType, albums] of Object.entries(albumGroups)) { const groupSection = document.createElement('section'); groupSection.className = 'album-group'; - + groupSection.innerHTML = `

${capitalize(groupType)}s

@@ -112,7 +129,7 @@ function renderArtist(artistData, artistId) {
+

Download Queue (0 items)

+
+ + +
+
`; document.body.insertAdjacentHTML('beforeend', queueHTML); - // Load initial visibility from server config + // Load initial config from the server. await this.loadConfig(); + + // Override the server value with locally persisted queue visibility (if present). + const storedVisible = localStorage.getItem("downloadQueueVisible"); + if (storedVisible !== null) { + this.currentConfig.downloadQueueVisible = storedVisible === "true"; + } + const queueSidebar = document.getElementById('downloadQueue'); queueSidebar.hidden = !this.currentConfig.downloadQueueVisible; queueSidebar.classList.toggle('active', this.currentConfig.downloadQueueVisible); @@ -60,6 +67,7 @@ class DownloadQueue { /* Event Handling */ initEventListeners() { + // Toggle queue visibility via Escape key. document.addEventListener('keydown', async (e) => { const queueSidebar = document.getElementById('downloadQueue'); if (e.key === 'Escape' && queueSidebar.classList.contains('active')) { @@ -67,6 +75,7 @@ class DownloadQueue { } }); + // Close queue when the close button is clicked. const queueSidebar = document.getElementById('downloadQueue'); if (queueSidebar) { queueSidebar.addEventListener('click', async (e) => { @@ -75,6 +84,32 @@ class DownloadQueue { } }); } + + // "Cancel all" button. + const cancelAllBtn = document.getElementById('cancelAllBtn'); + if (cancelAllBtn) { + cancelAllBtn.addEventListener('click', () => { + for (const queueId in this.downloadQueue) { + const entry = this.downloadQueue[queueId]; + if (!entry.hasEnded) { + fetch(`/api/${entry.type}/download/cancel?prg_file=${entry.prgFile}`) + .then(response => response.json()) + .then(data => { + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + if (logElement) logElement.textContent = "Download cancelled"; + entry.hasEnded = true; + if (entry.intervalId) { + clearInterval(entry.intervalId); + entry.intervalId = null; + } + // Cleanup the entry after a short delay. + setTimeout(() => this.cleanupEntry(queueId), 5000); + }) + .catch(error => console.error('Cancel error:', error)); + } + } + }); + } } /* Public API */ @@ -85,15 +120,17 @@ class DownloadQueue { queueSidebar.classList.toggle('active', isVisible); queueSidebar.hidden = !isVisible; + // Persist the state locally so it survives refreshes. + localStorage.setItem("downloadQueueVisible", isVisible); + try { - // Update config on server await this.loadConfig(); const updatedConfig = { ...this.currentConfig, downloadQueueVisible: isVisible }; await this.saveConfig(updatedConfig); this.dispatchEvent('queueVisibilityChanged', { visible: isVisible }); } catch (error) { console.error('Failed to save queue visibility:', error); - // Revert UI if save failed + // Revert UI if save failed. queueSidebar.classList.toggle('active', !isVisible); queueSidebar.hidden = isVisible; this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible }); @@ -110,49 +147,52 @@ class DownloadQueue { } /** - * Now accepts an extra argument "requestUrl" which is the same API call used to initiate the download. + * Adds a new download entry. */ addDownload(item, type, prgFile, requestUrl = null) { const queueId = this.generateQueueId(); const entry = this.createQueueEntry(item, type, prgFile, queueId, requestUrl); - this.downloadQueue[queueId] = entry; - document.getElementById('queueItems').appendChild(entry.element); - this.startEntryMonitoring(queueId); + // Re-render and update which entries are processed. + this.updateQueueOrder(); this.dispatchEvent('downloadAdded', { queueId, item, type }); } + /* Start processing the entry only if it is visible. */ async startEntryMonitoring(queueId) { const entry = this.downloadQueue[queueId]; if (!entry || entry.hasEnded) return; + if (entry.intervalId) return; entry.intervalId = setInterval(async () => { - // Use the current prgFile value stored in the entry to build the log element id. + if (!this.isEntryVisible(queueId)) { + clearInterval(entry.intervalId); + entry.intervalId = null; + return; + } const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); if (entry.hasEnded) { clearInterval(entry.intervalId); return; } - try { const response = await fetch(`/api/prgs/${entry.prgFile}`); const data = await response.json(); - // Update the entry type from the API response if available. if (data.type) { entry.type = data.type; } - // If the prg file info contains the original_request parameters and we haven't stored a retry URL yet, - // build one using the updated type and original_request parameters. if (!entry.requestUrl && data.original_request) { - const params = new URLSearchParams(data.original_request).toString(); - entry.requestUrl = `/api/${entry.type}/download?${params}`; + const params = new CustomURLSearchParams(); + for (const key in data.original_request) { + params.append(key, data.original_request[key]); + } + entry.requestUrl = `/api/${entry.type}/download?${params.toString()}`; } const progress = data.last_line; - // NEW: If the progress data exists but has no "status" parameter, ignore it. if (progress && typeof progress.status === 'undefined') { if (entry.type === 'playlist') { logElement.textContent = "Reading tracks list..."; @@ -160,7 +200,6 @@ class DownloadQueue { this.updateQueueOrder(); return; } - // If there's no progress at all, treat as inactivity. if (!progress) { if (entry.type === 'playlist') { logElement.textContent = "Reading tracks list..."; @@ -170,19 +209,22 @@ class DownloadQueue { this.updateQueueOrder(); return; } - - // If the new progress is the same as the last, also treat it as inactivity. if (JSON.stringify(entry.lastStatus) === JSON.stringify(progress)) { this.handleInactivity(entry, queueId, logElement); this.updateQueueOrder(); return; } + // Update the entry and cache. entry.lastStatus = progress; entry.lastUpdated = Date.now(); entry.status = progress.status; logElement.textContent = this.getStatusMessage(progress); + // Save updated status to cache. + this.queueCache[entry.prgFile] = progress; + localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); + if (['error', 'complete', 'cancel'].includes(progress.status)) { this.handleTerminalState(entry, queueId, progress); } @@ -193,39 +235,47 @@ class DownloadQueue { message: 'Status check error' }); } - // Reorder the queue display after updating the entry status. this.updateQueueOrder(); }, 2000); } - /* Helper Methods */ generateQueueId() { return Date.now().toString() + Math.random().toString(36).substr(2, 9); } /** - * Now accepts a fifth parameter "requestUrl" and stores it in the entry. + * Creates a new queue entry. It checks localStorage for any cached info. */ createQueueEntry(item, type, prgFile, queueId, requestUrl) { - return { + // Build the basic entry. + const entry = { item, type, prgFile, - requestUrl, // store the original API request URL so we can retry later + requestUrl, // for potential retry element: this.createQueueItem(item, type, prgFile, queueId), lastStatus: null, lastUpdated: Date.now(), hasEnded: false, intervalId: null, uniqueId: queueId, - retryCount: 0, // Initialize retry counter - autoRetryInterval: null // To store the countdown interval ID for auto retry + retryCount: 0, + autoRetryInterval: null }; + // If cached info exists for this PRG file, use it. + if (this.queueCache[prgFile]) { + entry.lastStatus = this.queueCache[prgFile]; + const logEl = entry.element.querySelector('.log'); + logEl.textContent = this.getStatusMessage(this.queueCache[prgFile]); + } + return entry; } + /** + * Returns an HTML element for the queue entry. + */ createQueueItem(item, type, prgFile, queueId) { - // Use "Reading track list" as the default message for playlists. const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...'; const div = document.createElement('article'); div.className = 'queue-item'; @@ -239,7 +289,6 @@ class DownloadQueue { Cancel Download `; - div.querySelector('.cancel-btn').addEventListener('click', (e) => this.handleCancelDownload(e)); return div; } @@ -247,12 +296,10 @@ class DownloadQueue { async handleCancelDownload(e) { const btn = e.target.closest('button'); btn.style.display = 'none'; - const { prg, type, queueid } = btn.dataset; try { - const response = await fetch(`/api/${type}/download/cancel?prg_file=${encodeURIComponent(prg)}`); + const response = await fetch(`/api/${type}/download/cancel?prg_file=${prg}`); const data = await response.json(); - if (data.status === "cancel") { const logElement = document.getElementById(`log-${queueid}-${prg}`); logElement.textContent = "Download cancelled"; @@ -260,6 +307,7 @@ class DownloadQueue { if (entry) { entry.hasEnded = true; clearInterval(entry.intervalId); + entry.intervalId = null; } setTimeout(() => this.cleanupEntry(queueid), 5000); } @@ -268,35 +316,116 @@ class DownloadQueue { } } - /* State Management */ - async loadExistingPrgFiles() { - try { - const response = await fetch('/api/prgs/list'); - const prgFiles = await response.json(); + /* Reorders the queue display, updates the total count, and handles "Show more" */ + updateQueueOrder() { + const container = document.getElementById('queueItems'); + const footer = document.getElementById('queueFooter'); + const entries = Object.values(this.downloadQueue); - for (const prgFile of prgFiles) { - const prgResponse = await fetch(`/api/prgs/${prgFile}`); - const prgData = await prgResponse.json(); - const dummyItem = { name: prgData.name || prgFile, external_urls: {} }; - // In this case, no original request URL is available. - this.addDownload(dummyItem, prgData.type || "unknown", prgFile); + // Sorting: errors/canceled first (group 0), ongoing next (group 1), queued last (group 2, sorted by position). + entries.sort((a, b) => { + const getGroup = (entry) => { + if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) { + return 0; + } else if (entry.lastStatus && entry.lastStatus.status === "queued") { + return 2; + } else { + return 1; + } + }; + const groupA = getGroup(a); + const groupB = getGroup(b); + if (groupA !== groupB) { + return groupA - groupB; + } else { + if (groupA === 2) { + const posA = a.lastStatus && a.lastStatus.position ? a.lastStatus.position : Infinity; + const posB = b.lastStatus && b.lastStatus.position ? b.lastStatus.position : Infinity; + return posA - posB; + } + return a.lastUpdated - b.lastUpdated; } - } catch (error) { - console.error('Error loading existing PRG files:', error); + }); + + document.getElementById('queueTotalCount').textContent = entries.length; + const visibleEntries = entries.slice(0, this.visibleCount); + container.innerHTML = ''; + visibleEntries.forEach(entry => { + container.appendChild(entry.element); + if (!entry.intervalId) { + this.startEntryMonitoring(entry.uniqueId); + } + }); + entries.slice(this.visibleCount).forEach(entry => { + if (entry.intervalId) { + clearInterval(entry.intervalId); + entry.intervalId = null; + } + }); + + footer.innerHTML = ''; + if (entries.length > this.visibleCount) { + const remaining = entries.length - this.visibleCount; + const showMoreBtn = document.createElement('button'); + showMoreBtn.textContent = `Show ${remaining} more`; + showMoreBtn.addEventListener('click', () => { + this.visibleCount += 10; + localStorage.setItem("downloadQueueVisibleCount", this.visibleCount); + this.updateQueueOrder(); + }); + footer.appendChild(showMoreBtn); } } + /* Checks if an entry is visible in the queue display. */ + isEntryVisible(queueId) { + const entries = Object.values(this.downloadQueue); + entries.sort((a, b) => { + const getGroup = (entry) => { + if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) { + return 0; + } else if (entry.lastStatus && entry.lastStatus.status === "queued") { + return 2; + } else { + return 1; + } + }; + const groupA = getGroup(a); + const groupB = getGroup(b); + if (groupA !== groupB) { + return groupA - groupB; + } else { + if (groupA === 2) { + const posA = a.lastStatus && a.lastStatus.position ? a.lastStatus.position : Infinity; + const posB = b.lastStatus && b.lastStatus.position ? b.lastStatus.position : Infinity; + return posA - posB; + } + return a.lastUpdated - b.lastUpdated; + } + }); + const index = entries.findIndex(e => e.uniqueId === queueId); + return index >= 0 && index < this.visibleCount; + } + cleanupEntry(queueId) { const entry = this.downloadQueue[queueId]; if (entry) { - clearInterval(entry.intervalId); + if (entry.intervalId) { + clearInterval(entry.intervalId); + } if (entry.autoRetryInterval) { clearInterval(entry.autoRetryInterval); } entry.element.remove(); delete this.downloadQueue[queueId]; - fetch(`/api/prgs/delete/${encodeURIComponent(entry.prgFile)}`, { method: 'DELETE' }) + // Remove the cached info. + if (this.queueCache[entry.prgFile]) { + delete this.queueCache[entry.prgFile]; + localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); + } + fetch(`/api/prgs/delete/${entry.prgFile}`, { method: 'DELETE' }) .catch(console.error); + this.updateQueueOrder(); } } @@ -307,39 +436,30 @@ class DownloadQueue { /* Status Message Handling */ getStatusMessage(data) { - // Helper function to format an array into a human-readable list without a comma before "and". function formatList(items) { if (!items || items.length === 0) return ''; if (items.length === 1) return items[0]; if (items.length === 2) return `${items[0]} and ${items[1]}`; return items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1]; } - - // Helper function for a simple pluralization: function pluralize(word) { return word.endsWith('s') ? word : word + 's'; } - switch (data.status) { case 'queued': - // Display a friendly message for queued items. if (data.type === 'album' || data.type === 'playlist') { - // Show the name and queue position if provided. return `Queued ${data.type} "${data.name}"${data.position ? ` (position ${data.position})` : ''}`; } else if (data.type === 'track') { return `Queued track "${data.name}"${data.artist ? ` by ${data.artist}` : ''}`; } return `Queued ${data.type} "${data.name}"`; - case 'cancel': return 'Download cancelled'; - case 'downloading': if (data.type === 'track') { return `Downloading track "${data.song}" by ${data.artist}...`; } return `Downloading ${data.type}...`; - case 'initializing': if (data.type === 'playlist') { return `Initializing playlist download "${data.name}" with ${data.total_tracks} tracks...`; @@ -362,13 +482,11 @@ class DownloadQueue { return `Initializing download for ${data.artist} with ${data.total_albums} album(s) [${data.album_type}]...`; } return `Initializing ${data.type} download...`; - case 'progress': if (data.track && data.current_track) { const parts = data.current_track.split('/'); const current = parts[0]; const total = parts[1] || '?'; - if (data.type === 'playlist') { return `Downloading playlist: Track ${current} of ${total} - ${data.track}`; } else if (data.type === 'album') { @@ -380,7 +498,6 @@ class DownloadQueue { } } return `Progress: ${data.status}...`; - case 'done': if (data.type === 'track') { return `Finished track "${data.song}" by ${data.artist}`; @@ -392,19 +509,14 @@ class DownloadQueue { return `Finished artist "${data.artist}" (${data.album_type})`; } return `Finished ${data.type}`; - case 'retrying': return `Track "${data.song}" by ${data.artist}" failed, retrying (${data.retry_count}/5) in ${data.seconds_left}s`; - case 'error': return `Error: ${data.message || 'Unknown error'}`; - case 'complete': return 'Download completed successfully'; - case 'skipped': return `Track "${data.song}" skipped, it already exists!`; - case 'real_time': { const totalMs = data.time_elapsed; const minutes = Math.floor(totalMs / 60000); @@ -412,30 +524,22 @@ class DownloadQueue { const paddedSeconds = seconds < 10 ? '0' + seconds : seconds; return `Real-time downloading track "${data.song}" by ${data.artist} (${(data.percentage * 100).toFixed(1)}%). Time elapsed: ${minutes}:${paddedSeconds}`; } - default: return data.status; } } - /* New Methods to Handle Terminal State, Inactivity and Auto-Retry */ - handleTerminalState(entry, queueId, progress) { - // Mark the entry as ended and clear its monitoring interval. entry.hasEnded = true; clearInterval(entry.intervalId); const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); if (!logElement) return; - if (progress.status === 'error') { - // Hide the cancel button. const cancelBtn = entry.element.querySelector('.cancel-btn'); if (cancelBtn) { cancelBtn.style.display = 'none'; } - - // Display error message with retry buttons. logElement.innerHTML = `
${this.getStatusMessage(progress)}
@@ -443,18 +547,13 @@ class DownloadQueue {
`; - - // Close (X) button: immediately remove the queue entry. logElement.querySelector('.close-error-btn').addEventListener('click', () => { - // If an auto-retry countdown is running, clear it. if (entry.autoRetryInterval) { clearInterval(entry.autoRetryInterval); entry.autoRetryInterval = null; } this.cleanupEntry(queueId); }); - - // Manual Retry button: cancel the auto-retry timer (if running) and retry immediately. logElement.querySelector('.retry-btn').addEventListener('click', async () => { if (entry.autoRetryInterval) { clearInterval(entry.autoRetryInterval); @@ -462,16 +561,11 @@ class DownloadQueue { } this.retryDownload(queueId, logElement); }); - - // --- Auto-Retry Logic --- - // Only auto-retry if we have a requestUrl. if (entry.requestUrl) { const maxRetries = 10; if (entry.retryCount < maxRetries) { - const autoRetryDelay = 300; // seconds (5 minutes) + const autoRetryDelay = 300; // seconds let secondsLeft = autoRetryDelay; - - // Start a countdown that updates the error message every second. entry.autoRetryInterval = setInterval(() => { secondsLeft--; const errorMsgEl = logElement.querySelector('.error-message'); @@ -486,17 +580,20 @@ class DownloadQueue { }, 1000); } } - // Do not automatically clean up if an error occurred. return; } else { - // For non-error terminal states, update the message and then clean up after 5 seconds. logElement.textContent = this.getStatusMessage(progress); setTimeout(() => this.cleanupEntry(queueId), 5000); } } handleInactivity(entry, queueId, logElement) { - // If no update in 5 minutes (300,000ms), treat as an error. + if (entry.lastStatus && entry.lastStatus.status === 'queued') { + if (logElement) { + logElement.textContent = this.getStatusMessage(entry.lastStatus); + } + return; + } const now = Date.now(); if (now - entry.lastUpdated > 300000) { const progress = { status: 'error', message: 'Inactivity timeout' }; @@ -508,13 +605,9 @@ class DownloadQueue { } } - /** - * retryDownload() handles both manual and automatic retries. - */ async retryDownload(queueId, logElement) { const entry = this.downloadQueue[queueId]; if (!entry) return; - logElement.textContent = 'Retrying download...'; if (!entry.requestUrl) { logElement.textContent = 'Retry not available: missing original request information.'; @@ -524,23 +617,16 @@ class DownloadQueue { const retryResponse = await fetch(entry.requestUrl); const retryData = await retryResponse.json(); if (retryData.prg_file) { - // Delete the failed prg file before updating to the new one. const oldPrgFile = entry.prgFile; - await fetch(`/api/prgs/delete/${encodeURIComponent(oldPrgFile)}`, { method: 'DELETE' }); - - // Update the log element's id to reflect the new prg_file. + await fetch(`/api/prgs/delete/${oldPrgFile}`, { method: 'DELETE' }); const logEl = entry.element.querySelector('.log'); logEl.id = `log-${entry.uniqueId}-${retryData.prg_file}`; - - // Update the entry with the new prg_file and reset its state. entry.prgFile = retryData.prg_file; entry.lastStatus = null; entry.hasEnded = false; entry.lastUpdated = Date.now(); entry.retryCount = (entry.retryCount || 0) + 1; logEl.textContent = 'Retry initiated...'; - - // Restart monitoring using the new prg_file. this.startEntryMonitoring(queueId); } else { logElement.textContent = 'Retry failed: invalid response from server'; @@ -552,51 +638,23 @@ class DownloadQueue { /** * Builds common URL parameters for download API requests. - * - * Correction: When fallback is enabled for Spotify downloads, the active accounts - * are now used correctly as follows: - * - * - When fallback is true: - * • main = config.deezer - * • fallback = config.spotify - * • quality = config.deezerQuality - * • fall_quality = config.spotifyQuality - * - * - When fallback is false: - * • main = config.spotify - * • quality = config.spotifyQuality - * - * For Deezer downloads, always use: - * • main = config.deezer - * • quality = config.deezerQuality */ _buildCommonParams(url, service, config) { - // --- MODIFIED: Use our custom parameter builder for Spotify --- - let params; - if (service === 'spotify') { - params = new CustomURLSearchParams(['url']); // Do not encode the "url" parameter. - } else { - params = new URLSearchParams(); - } - // --- END MODIFIED --- - + const params = new CustomURLSearchParams(); params.append('service', service); params.append('url', url); if (service === 'spotify') { if (config.fallback) { - // Fallback enabled: use the active Deezer account as main and Spotify as fallback. params.append('main', config.deezer); params.append('fallback', config.spotify); params.append('quality', config.deezerQuality); params.append('fall_quality', config.spotifyQuality); } else { - // Fallback disabled: use only the Spotify active account. params.append('main', config.spotify); params.append('quality', config.spotifyQuality); } } else { - // For Deezer, always use the active Deezer account. params.append('main', config.deezer); params.append('quality', config.deezerQuality); } @@ -620,11 +678,9 @@ class DownloadQueue { await this.loadConfig(); const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer'; const params = this._buildCommonParams(url, service, this.currentConfig); - // Add the extra parameters "name" and "artist" params.append('name', item.name || ''); params.append('artist', item.artist || ''); const apiUrl = `/api/track/download?${params.toString()}`; - try { const response = await fetch(apiUrl); if (!response.ok) throw new Error('Network error'); @@ -640,11 +696,9 @@ class DownloadQueue { await this.loadConfig(); const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer'; const params = this._buildCommonParams(url, service, this.currentConfig); - // Add the extra parameters "name" and "artist" params.append('name', item.name || ''); params.append('artist', item.artist || ''); const apiUrl = `/api/playlist/download?${params.toString()}`; - try { const response = await fetch(apiUrl); const data = await response.json(); @@ -655,15 +709,38 @@ class DownloadQueue { } } + async startArtistDownload(url, item, albumType = 'album,single,compilation,appears_on') { + 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()}`; + try { + const response = await fetch(apiUrl); + if (!response.ok) throw new Error('Network error'); + const data = await response.json(); + if (data.album_prg_files && Array.isArray(data.album_prg_files)) { + data.album_prg_files.forEach(prgFile => { + this.addDownload(item, 'album', prgFile, apiUrl); + }); + } else if (data.prg_file) { + this.addDownload(item, 'album', data.prg_file, apiUrl); + } + } catch (error) { + this.dispatchEvent('downloadError', { error, item }); + throw error; + } + } + async startAlbumDownload(url, item) { await this.loadConfig(); const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer'; const params = this._buildCommonParams(url, service, this.currentConfig); - // Add the extra parameters "name" and "artist" params.append('name', item.name || ''); params.append('artist', item.artist || ''); const apiUrl = `/api/album/download?${params.toString()}`; - try { const response = await fetch(apiUrl); const data = await response.json(); @@ -673,24 +750,40 @@ class DownloadQueue { throw error; } } - - 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); - // Add the extra parameters "name" and "artist" - params.append('name', item.name || ''); - params.append('artist', item.artist || ''); - const apiUrl = `/api/artist/download?${params.toString()}`; + /** + * Loads existing PRG files from the /api/prgs/list endpoint and adds them as queue entries. + */ + async loadExistingPrgFiles() { try { - const response = await fetch(apiUrl); - const data = await response.json(); - this.addDownload(item, 'artist', data.prg_file, apiUrl); + const response = await fetch('/api/prgs/list'); + const prgFiles = await response.json(); + + // Sort filenames by the numeric portion (assumes format "type_number.prg"). + prgFiles.sort((a, b) => { + const numA = parseInt(a.split('_')[1]); + const numB = parseInt(b.split('_')[1]); + return numA - numB; + }); + + // Iterate through each PRG file and add it as a dummy queue entry. + for (const prgFile of prgFiles) { + try { + const prgResponse = await fetch(`/api/prgs/${prgFile}`); + if (!prgResponse.ok) continue; + const prgData = await prgResponse.json(); + 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' + }; + this.addDownload(dummyItem, dummyItem.type, prgFile); + } catch (error) { + console.error("Error fetching details for", prgFile, error); + } + } } catch (error) { - this.dispatchEvent('downloadError', { error, item }); - throw error; + console.error("Error loading existing PRG files:", error); } } @@ -705,7 +798,6 @@ class DownloadQueue { } } - // Placeholder for saveConfig; implement as needed. async saveConfig(updatedConfig) { try { const response = await fetch('/api/config', { @@ -720,53 +812,6 @@ class DownloadQueue { throw error; } } - /** - * Reorders the download queue display so that: - * - Errored (or canceled) downloads come first (Group 0) - * - Ongoing downloads come next (Group 1) - * - Queued downloads come last (Group 2), ordered by their position value. - */ - updateQueueOrder() { - const container = document.getElementById('queueItems'); - const entries = Object.values(this.downloadQueue); - - entries.sort((a, b) => { - // Define groups: - // Group 0: Errored or canceled downloads - // Group 2: Queued downloads - // Group 1: All others (ongoing) - const getGroup = (entry) => { - if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) { - return 0; - } else if (entry.lastStatus && entry.lastStatus.status === "queued") { - return 2; - } else { - return 1; - } - }; - - const groupA = getGroup(a); - const groupB = getGroup(b); - if (groupA !== groupB) { - return groupA - groupB; - } else { - // For queued downloads, order by their "position" value (smallest first) - if (groupA === 2) { - const posA = a.lastStatus && a.lastStatus.position ? a.lastStatus.position : Infinity; - const posB = b.lastStatus && b.lastStatus.position ? b.lastStatus.position : Infinity; - return posA - posB; - } - // For errored or ongoing downloads, order by last update time (oldest first) - return a.lastUpdated - b.lastUpdated; - } - }); - - // Clear the container and re-append entries in sorted order. - container.innerHTML = ''; - for (const entry of entries) { - container.appendChild(entry.element); - } - } } // Singleton instance