From 3add9917c82e7b889a26afbc8eb042aa83213a55 Mon Sep 17 00:00:00 2001 From: coolgitternotin Date: Mon, 24 Mar 2025 20:28:17 -0600 Subject: [PATCH 1/6] fixed retry logic --- static/js/queue.js | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/static/js/queue.js b/static/js/queue.js index 4f9bf28..a878052 100644 --- a/static/js/queue.js +++ b/static/js/queue.js @@ -864,6 +864,8 @@ class DownloadQueue { const entry = this.queueEntries[queueId]; if (!entry) return; + // Mark the entry as retrying to prevent automatic cleanup + entry.isRetrying = true; logElement.textContent = 'Retrying download...'; // Find a retry URL from various possible sources @@ -890,6 +892,7 @@ class DownloadQueue { // If we don't have any retry URL, show error if (!retryUrl) { logElement.textContent = 'Retry not available: missing URL information.'; + entry.isRetrying = false; // Reset retrying flag return; } @@ -956,10 +959,12 @@ class DownloadQueue { } } else { logElement.textContent = 'Retry failed: invalid response from server'; + entry.isRetrying = false; // Reset retrying flag } } catch (error) { console.error('Retry error:', error); logElement.textContent = 'Retry failed: ' + error.message; + entry.isRetrying = false; // Reset retrying flag } } @@ -1376,10 +1381,23 @@ class DownloadQueue { console.log(`Terminal state detected: ${data.last_line.status} for ${queueId}`); entry.hasEnded = true; - setTimeout(() => { - this.clearPollingInterval(queueId); - this.cleanupEntry(queueId); - }, 5000); + // Only set up cleanup if this is not an error that we're in the process of retrying + // If status is 'error' but the status message contains 'Retrying', don't clean up + const isRetrying = entry.isRetrying || + (data.last_line.status === 'error' && + entry.element.querySelector('.log')?.textContent?.includes('Retry')); + + if (!isRetrying) { + setTimeout(() => { + // Double-check the entry still exists and has not been retried before cleaning up + if (this.queueEntries[queueId] && + !this.queueEntries[queueId].isRetrying && + this.queueEntries[queueId].hasEnded) { + this.clearPollingInterval(queueId); + this.cleanupEntry(queueId); + } + }, 5000); + } } } catch (error) { @@ -1566,12 +1584,8 @@ class DownloadQueue { this.retryDownload(queueId, logElement); }); - // Set up automatic cleanup after 10 seconds - setTimeout(() => { - if (this.queueEntries[queueId] && this.queueEntries[queueId].hasEnded) { - this.cleanupEntry(queueId); - } - }, 10000); + // Don't set up automatic cleanup - let retryDownload function handle this + // The automatic cleanup was causing items to disappear when retrying } else { // Cannot retry - just show error with close button logElement.innerHTML = ` @@ -1585,9 +1599,9 @@ class DownloadQueue { this.cleanupEntry(queueId); }); - // Set up automatic cleanup after 10 seconds + // Set up automatic cleanup after 10 seconds only if not retrying setTimeout(() => { - if (this.queueEntries[queueId] && this.queueEntries[queueId].hasEnded) { + if (this.queueEntries[queueId] && this.queueEntries[queueId].hasEnded && !this.queueEntries[queueId].isRetrying) { this.cleanupEntry(queueId); } }, 10000); From b00115792a6099e652d3a5d2bd77643cd410eb33 Mon Sep 17 00:00:00 2001 From: "architect.in.git" Date: Tue, 22 Apr 2025 20:49:19 -0600 Subject: [PATCH 2/6] Added progress bars for overall and per track downloading, implemented new version of deezspot-fork-again, simplifided api to just the url (might reduce that to just the id in the future, idk). --- .gitignore | 1 + routes/prgs.py | 142 +--- static/css/queue.css | 1 - static/css/queue/queue.css | 90 +++ static/js/queue.js | 1326 +++++++++++++++++++++++++++++------- 5 files changed, 1189 insertions(+), 371 deletions(-) delete mode 100644 static/css/queue.css diff --git a/.gitignore b/.gitignore index 77f64e1..f53d8b2 100755 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ queue_state.json search_demo.py celery_worker.log logs/spotizerr.log +/.venv \ No newline at end of file diff --git a/routes/prgs.py b/routes/prgs.py index 598c075..0291039 100755 --- a/routes/prgs.py +++ b/routes/prgs.py @@ -52,123 +52,14 @@ def get_prg_file(task_id): status_count = len(all_statuses) logger.debug(f"API: Task {task_id} has {status_count} status updates") - # Prepare the response with basic info + # Prepare the simplified response with just the requested info response = { - "type": task_info.get("type", ""), - "name": task_info.get("name", ""), - "artist": task_info.get("artist", ""), "last_line": last_status, - "original_request": original_request, - "display_title": original_request.get("display_title", task_info.get("name", "")), - "display_type": original_request.get("display_type", task_info.get("type", "")), - "display_artist": original_request.get("display_artist", task_info.get("artist", "")), - "status_count": status_count, + "timestamp": time.time(), "task_id": task_id, - "timestamp": time.time() + "status_count": status_count } - # Handle different status types - if last_status: - status_type = last_status.get("status", "unknown") - - # Set event type based on status (like in the previous SSE implementation) - event_type = "update" - if status_type in [ProgressState.COMPLETE, ProgressState.DONE]: - event_type = "complete" - elif status_type == ProgressState.TRACK_COMPLETE: - event_type = "track_complete" - elif status_type == ProgressState.ERROR: - event_type = "error" - elif status_type in [ProgressState.TRACK_PROGRESS, ProgressState.REAL_TIME]: - event_type = "progress" - - response["event"] = event_type - - # For terminal statuses (complete, error, cancelled) - if status_type in [ProgressState.COMPLETE, ProgressState.ERROR, ProgressState.CANCELLED]: - response["progress_message"] = last_status.get("message", f"Download {status_type}") - - # For progress status with track information - elif status_type == "progress" and last_status.get("track"): - # Add explicit track progress fields to the top level for easy access - response["current_track"] = last_status.get("track", "") - response["track_number"] = last_status.get("parsed_current_track", 0) - response["total_tracks"] = last_status.get("parsed_total_tracks", 0) - response["progress_percent"] = last_status.get("overall_progress", 0) - response["album"] = last_status.get("album", "") - - # Format a nice progress message for display - track_info = last_status.get("track", "") - current = last_status.get("parsed_current_track", 0) - total = last_status.get("parsed_total_tracks", 0) - progress = last_status.get("overall_progress", 0) - - if current and total: - response["progress_message"] = f"Downloading track {current}/{total} ({progress}%): {track_info}" - elif track_info: - response["progress_message"] = f"Downloading: {track_info}" - - # For real-time status messages - elif status_type == "real_time": - # Add real-time specific fields - response["current_song"] = last_status.get("song", "") - response["percent"] = last_status.get("percent", 0) - response["percentage"] = last_status.get("percentage", 0) - response["time_elapsed"] = last_status.get("time_elapsed", 0) - - # Format a nice progress message for display - song = last_status.get("song", "") - percent = last_status.get("percent", 0) - if song: - response["progress_message"] = f"Downloading {song} ({percent}%)" - else: - response["progress_message"] = f"Downloading ({percent}%)" - - # For initializing status - elif status_type == "initializing": - album = last_status.get("album", "") - if album: - response["progress_message"] = f"Initializing download for {album}" - else: - response["progress_message"] = "Initializing download..." - - # For processing status (default) - elif status_type == "processing": - # Search for the most recent track progress in all statuses - has_progress = False - for status in reversed(all_statuses): - if status.get("status") == "progress" and status.get("track"): - # Use this track progress information - track_info = status.get("track", "") - current_raw = status.get("current_track", "") - response["current_track"] = track_info - - # Try to parse track numbers if available - if isinstance(current_raw, str) and "/" in current_raw: - try: - parts = current_raw.split("/") - current = int(parts[0]) - total = int(parts[1]) - response["track_number"] = current - response["total_tracks"] = total - response["progress_percent"] = min(int((current / total) * 100), 100) - response["progress_message"] = f"Processing track {current}/{total}: {track_info}" - except (ValueError, IndexError): - response["progress_message"] = f"Processing: {track_info}" - else: - response["progress_message"] = f"Processing: {track_info}" - - has_progress = True - break - - if not has_progress: - # Just use the processing message - response["progress_message"] = last_status.get("message", "Processing download...") - - # For other status types - else: - response["progress_message"] = last_status.get("message", f"Status: {status_type}") - return jsonify(response) # If not found in new system, try the old PRG file system @@ -182,19 +73,13 @@ def get_prg_file(task_id): content = f.read() lines = content.splitlines() - # If the file is empty, return default values. + # If the file is empty, return default values with simplified format. if not lines: return jsonify({ - "type": "", - "name": "", - "artist": "", "last_line": None, - "original_request": None, - "display_title": "", - "display_type": "", - "display_artist": "", + "timestamp": time.time(), "task_id": task_id, - "event": "unknown" + "status_count": 0 }) # Attempt to extract the original request from the first line. @@ -248,18 +133,15 @@ def get_prg_file(task_id): except Exception: last_line_parsed = last_line_raw # Fallback to raw string if JSON parsing fails. + # Calculate status_count for old PRG files (number of lines in the file) + status_count = len(lines) + + # Return simplified response format return jsonify({ - "type": resource_type, - "name": resource_name, - "artist": resource_artist, "last_line": last_line_parsed, - "original_request": original_request, - "display_title": display_title, - "display_type": display_type, - "display_artist": display_artist, + "timestamp": time.time(), "task_id": task_id, - "event": "unknown", # Old files don't have event types - "timestamp": time.time() + "status_count": status_count }) except FileNotFoundError: abort(404, "Task or file not found") diff --git a/static/css/queue.css b/static/css/queue.css deleted file mode 100644 index 0519ecb..0000000 --- a/static/css/queue.css +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/css/queue/queue.css b/static/css/queue/queue.css index c499a1c..4cc80e0 100644 --- a/static/css/queue/queue.css +++ b/static/css/queue/queue.css @@ -310,6 +310,96 @@ border-radius: 2px; } +/* Overall progress container for albums and playlists */ +.overall-progress-container { + margin-top: 12px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + position: relative; /* Positioning context for z-index */ + z-index: 2; /* Ensure overall progress appears above track progress */ +} + +.overall-progress-header { + display: flex; + justify-content: space-between; + margin-bottom: 5px; + font-size: 11px; + color: #b3b3b3; +} + +.overall-progress-label { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.overall-progress-count { + font-weight: 600; + color: #1DB954; +} + +.overall-progress-bar-container { + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; +} + +.overall-progress-bar { + height: 100%; + background: linear-gradient(90deg, #4a90e2, #7a67ee); /* Changed to blue-purple gradient */ + width: 0; + border-radius: 3px; + transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; +} + +.overall-progress-bar.complete { + background: #4a90e2; /* Changed to solid blue for completed overall progress */ +} + +/* Track progress bar container */ +.track-progress-bar-container { + height: 4px; + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; + overflow: hidden; + margin-top: 8px; + margin-bottom: 4px; + position: relative; + z-index: 1; /* Ensure it's below the overall progress */ +} + +/* Track progress bar */ +.track-progress-bar { + height: 100%; + background: #1DB954; /* Keep green for track-level progress */ + width: 0; + border-radius: 2px; + transition: width 0.3s ease; + box-shadow: 0 0 3px rgba(29, 185, 84, 0.5); /* Add subtle glow to differentiate */ +} + +/* Complete state for track progress */ +/* Real-time progress style */ +.track-progress-bar.real-time { + background: #1DB954; /* Vivid green for real-time progress */ + background: #1DB954; +} + +/* Pulsing animation for indeterminate progress */ +.track-progress-bar.progress-pulse { + background: linear-gradient(90deg, #1DB954 0%, #2cd267 50%, #1DB954 100%); /* Keep in green family */ + background-size: 200% 100%; + animation: progress-pulse-slide 1.5s ease infinite; +} + +@keyframes progress-pulse-slide { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + /* Progress percentage text */ .progress-percent { text-align: right; diff --git a/static/js/queue.js b/static/js/queue.js index a878052..44987fd 100644 --- a/static/js/queue.js +++ b/static/js/queue.js @@ -27,8 +27,8 @@ class DownloadQueue { // Queue entry objects this.queueEntries = {}; - // EventSource connections for SSE tracking - this.sseConnections = {}; + // Polling intervals for progress tracking + this.pollingIntervals = {}; // DOM elements cache this.elements = {}; @@ -117,27 +117,34 @@ class DownloadQueue { cancelAllBtn.addEventListener('click', () => { for (const queueId in this.queueEntries) { const entry = this.queueEntries[queueId]; - if (!entry.hasEnded) { + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + if (entry && !entry.hasEnded && entry.prgFile) { + // Mark as cancelling visually + if (entry.element) { + entry.element.classList.add('cancelling'); + } + if (logElement) { + logElement.textContent = "Cancelling..."; + } + + // Cancel each active download 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; - - // Close SSE connection - this.clearPollingInterval(queueId); - - if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + if (data.status === "cancel") { + entry.hasEnded = true; + if (entry.intervalId) { + clearInterval(entry.intervalId); + entry.intervalId = null; + } + // Clean up immediately + this.cleanupEntry(queueId); } - // Cleanup the entry after a short delay. - setTimeout(() => this.cleanupEntry(queueId), 5000); }) .catch(error => console.error('Cancel error:', error)); } } + this.clearAllPollingIntervals(); }); } @@ -234,8 +241,11 @@ class DownloadQueue { const entry = this.queueEntries[queueId]; if (!entry || entry.hasEnded) return; - // Don't restart monitoring if SSE connection already exists - if (this.sseConnections[queueId]) return; + // Don't restart monitoring if polling interval already exists + if (this.pollingIntervals[queueId]) return; + + // Ensure entry has data containers for parent info + entry.parentInfo = entry.parentInfo || {}; // Show a preparing message for new entries if (entry.isNew) { @@ -290,11 +300,50 @@ class DownloadQueue { // Apply appropriate CSS classes based on status this.applyStatusClasses(entry, data.last_line); - // Save updated status to cache - this.queueCache[entry.prgFile] = data.last_line; + // Save updated status to cache, ensuring we preserve parent data + this.queueCache[entry.prgFile] = { + ...data.last_line, + // Ensure parent data is preserved + parent: data.last_line.parent || entry.lastStatus?.parent + }; + + // If this is a track with a parent, update the display elements to match the parent + if (data.last_line.type === 'track' && data.last_line.parent) { + const parent = data.last_line.parent; + entry.parentInfo = parent; + + // Update type and UI to reflect the parent type + if (parent.type === 'album' || parent.type === 'playlist') { + // Only change type if it's not already set to the parent type + if (entry.type !== parent.type) { + entry.type = parent.type; + + // Update the type indicator + const typeEl = entry.element.querySelector('.type'); + if (typeEl) { + const displayType = parent.type.charAt(0).toUpperCase() + parent.type.slice(1); + typeEl.textContent = displayType; + typeEl.className = `type ${parent.type}`; + } + + // Update the title and subtitle based on parent type + const titleEl = entry.element.querySelector('.title'); + const artistEl = entry.element.querySelector('.artist'); + + if (parent.type === 'album') { + if (titleEl) titleEl.textContent = parent.title || 'Unknown album'; + if (artistEl) artistEl.textContent = parent.artist || 'Unknown artist'; + } else if (parent.type === 'playlist') { + if (titleEl) titleEl.textContent = parent.name || 'Unknown playlist'; + if (artistEl) artistEl.textContent = parent.owner || 'Unknown creator'; + } + } + } + } + localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); - // If the entry is already in a terminal state, don't set up SSE + // If the entry is already in a terminal state, don't set up polling if (['error', 'complete', 'cancel', 'cancelled', 'done'].includes(data.last_line.status)) { entry.hasEnded = true; this.handleDownloadCompletion(entry, queueId, data.last_line); @@ -306,7 +355,7 @@ class DownloadQueue { console.error('Initial status check failed:', error); } - // Set up SSE connection for real-time updates + // Set up polling interval for real-time updates this.setupPollingInterval(queueId); } @@ -321,14 +370,65 @@ class DownloadQueue { createQueueEntry(item, type, prgFile, queueId, requestUrl) { console.log(`Creating queue entry with initial type: ${type}`); - // Build the basic entry. + // Get cached data if it exists + const cachedData = this.queueCache[prgFile]; + + // If we have cached data, use it to determine the true type and item properties + if (cachedData) { + // If this is a track with a parent, update type and item to match the parent + if (cachedData.type === 'track' && cachedData.parent) { + if (cachedData.parent.type === 'album') { + type = 'album'; + item = { + name: cachedData.parent.title, + artist: cachedData.parent.artist, + total_tracks: cachedData.parent.total_tracks, + url: cachedData.parent.url + }; + } else if (cachedData.parent.type === 'playlist') { + type = 'playlist'; + item = { + name: cachedData.parent.name, + owner: cachedData.parent.owner, + total_tracks: cachedData.parent.total_tracks, + url: cachedData.parent.url + }; + } + } + // If we're reconstructing an album or playlist directly + else if (cachedData.type === 'album') { + item = { + name: cachedData.title || cachedData.album || 'Unknown album', + artist: cachedData.artist || 'Unknown artist', + total_tracks: cachedData.total_tracks || 0 + }; + } else if (cachedData.type === 'playlist') { + item = { + name: cachedData.name || 'Unknown playlist', + owner: cachedData.owner || 'Unknown creator', + total_tracks: cachedData.total_tracks || 0 + }; + } + } + + // Build the basic entry with possibly updated type and item const entry = { item, - type, + type, prgFile, requestUrl, // for potential retry element: this.createQueueItem(item, type, prgFile, queueId), - lastStatus: null, + lastStatus: { + // Initialize with basic item metadata for immediate display + type, + status: 'initializing', + name: item.name || 'Unknown', + artist: item.artist || item.artists?.[0]?.name || '', + album: item.album?.name || '', + title: item.name || '', + owner: item.owner || item.owner?.display_name || '', + total_tracks: item.total_tracks || 0 + }, lastUpdated: Date.now(), hasEnded: false, intervalId: null, @@ -337,14 +437,20 @@ class DownloadQueue { autoRetryInterval: null, isNew: true, // Add flag to track if this is a new entry status: 'initializing', - lastMessage: `Initializing ${type} download...` + lastMessage: `Initializing ${type} download...`, + parentInfo: null // Will store parent data for tracks that are part of albums/playlists }; // If cached info exists for this PRG file, use it. - if (this.queueCache[prgFile]) { - entry.lastStatus = this.queueCache[prgFile]; + if (cachedData) { + entry.lastStatus = cachedData; const logEl = entry.element.querySelector('.log'); + // Store parent information if available + if (cachedData.parent) { + entry.parentInfo = cachedData.parent; + } + // Special handling for error states to restore UI with buttons if (entry.lastStatus.status === 'error') { // Hide the cancel button if in error state @@ -411,37 +517,78 @@ class DownloadQueue { } /** - * Returns an HTML element for the queue entry. - */ - 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 - // Support both 'name' and 'music' fields which may be used by the backend - const displayTitle = item.name || item.music || item.song || 'Unknown'; - const displayType = type.charAt(0).toUpperCase() + type.slice(1); - - const div = document.createElement('article'); - div.className = 'queue-item queue-item-new'; // Add the animation class - div.setAttribute('aria-live', 'polite'); - div.setAttribute('aria-atomic', 'true'); - div.innerHTML = ` -
${displayTitle}
-
${displayType}
-
${defaultMessage}
+ * Returns an HTML element for the queue entry with modern UI styling. + */ +createQueueItem(item, type, prgFile, queueId) { + // Track whether this is a multi-track item (album or playlist) + const isMultiTrack = type === 'album' || type === 'playlist'; + const defaultMessage = (type === 'playlist') ? 'Reading track list' : 'Initializing download...'; + + // Use display values if available, or fall back to standard fields + const displayTitle = item.name || item.music || item.song || 'Unknown'; + const displayArtist = item.artist || ''; + const displayType = type.charAt(0).toUpperCase() + type.slice(1); + + const div = document.createElement('article'); + div.className = 'queue-item queue-item-new'; // Add the animation class + div.setAttribute('aria-live', 'polite'); + div.setAttribute('aria-atomic', 'true'); + div.setAttribute('data-type', type); + + // Create modern HTML structure with better visual hierarchy + let innerHtml = ` +
+
+
${displayTitle}
+ ${displayArtist ? `
${displayArtist}
` : ''} +
${displayType}
+
- `; - div.querySelector('.cancel-btn').addEventListener('click', (e) => this.handleCancelDownload(e)); +
- // Remove the animation class after animation completes - setTimeout(() => { - div.classList.remove('queue-item-new'); - }, 300); // Match the animation duration - - return div; +
+
${defaultMessage}
+ +
+ +
+
+
+ + +
+
+
`; + + // For albums and playlists, add an overall progress container + if (isMultiTrack) { + innerHtml += ` +
+
+ Overall Progress + 0/0 +
+
+
+
+
`; } + + div.innerHTML = innerHtml; + + div.querySelector('.cancel-btn').addEventListener('click', (e) => this.handleCancelDownload(e)); + + // Remove the animation class after animation completes + setTimeout(() => { + div.classList.remove('queue-item-new'); + }, 300); // Match the animation duration + + return div; +} // Add a helper method to apply the right CSS classes based on status applyStatusClasses(entry, status) { @@ -489,13 +636,23 @@ class DownloadQueue { btn.style.display = 'none'; const { prg, type, queueid } = btn.dataset; try { + // Get the queue item element + const entry = this.queueEntries[queueid]; + if (entry && entry.element) { + // Add a visual indication that it's being cancelled + entry.element.classList.add('cancelling'); + } + + // Show cancellation in progress + const logElement = document.getElementById(`log-${queueid}-${prg}`); + if (logElement) { + logElement.textContent = "Cancelling..."; + } + // First cancel the download 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"; - const entry = this.queueEntries[queueid]; if (entry) { entry.hasEnded = true; @@ -512,7 +669,7 @@ class DownloadQueue { this.queueCache[prg] = { status: "cancelled" }; localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); - // Immediately delete from server instead of just waiting for UI cleanup + // Immediately delete from server try { await fetch(`/api/prgs/delete/${prg}`, { method: 'DELETE' @@ -521,10 +678,10 @@ class DownloadQueue { } catch (deleteError) { console.error('Error deleting cancelled task:', deleteError); } + + // Immediately remove the item from the UI + this.cleanupEntry(queueid); } - - // Still do UI cleanup after a short delay - setTimeout(() => this.cleanupEntry(queueid), 5000); } } catch (error) { console.error('Cancel error:', error); @@ -670,7 +827,7 @@ class DownloadQueue { async cleanupEntry(queueId) { const entry = this.queueEntries[queueId]; if (entry) { - // Close any SSE connection + // Close any polling interval this.clearPollingInterval(queueId); // Clean up any intervals @@ -717,82 +874,217 @@ class DownloadQueue { /* Status Message Handling */ getStatusMessage(data) { + // Determine the true display type - if this is a track with a parent, we may want to + // show it as part of the parent's download process + let displayType = data.type || 'unknown'; + let isChildTrack = false; + + // If this is a track that's part of an album/playlist, note that + if (data.type === 'track' && data.parent) { + isChildTrack = true; + // We'll still use track-specific info but note it's part of a parent + } + + // Find the queue item this status belongs to + let queueItem = null; + const prgFile = data.prg_file || Object.keys(this.queueCache).find(key => + this.queueCache[key].status === data.status && this.queueCache[key].type === data.type + ); + + if (prgFile) { + const queueId = Object.keys(this.queueEntries).find(id => + this.queueEntries[id].prgFile === prgFile + ); + if (queueId) { + queueItem = this.queueEntries[queueId]; + } + } + // Extract common fields - const trackName = data.music || data.song || data.name || 'Unknown'; - const artist = data.artist || ''; - const percentage = data.percentage || data.percent || 0; - const currentTrack = data.parsed_current_track || data.current_track || ''; - const totalTracks = data.parsed_total_tracks || data.total_tracks || ''; - const playlistOwner = data.owner || ''; + const trackName = data.song || data.music || data.name || data.title || + (queueItem?.item?.name) || 'Unknown'; + const artist = data.artist || data.artist_name || + (queueItem?.item?.artist) || ''; + const albumTitle = data.title || data.album || data.parent?.title || data.name || + (queueItem?.item?.name) || ''; + const playlistName = data.name || data.parent?.name || + (queueItem?.item?.name) || ''; + const playlistOwner = data.owner || data.parent?.owner || + (queueItem?.item?.owner) || ''; + const currentTrack = data.current_track || data.parsed_current_track || ''; + const totalTracks = data.total_tracks || data.parsed_total_tracks || data.parent?.total_tracks || + (queueItem?.item?.total_tracks) || ''; - // Format percentage for display - const formattedPercentage = (percentage * 100).toFixed(1); + // Format percentage for display when available + let formattedPercentage = '0'; + if (data.progress !== undefined) { + formattedPercentage = parseFloat(data.progress).toFixed(1); + } else if (data.percentage) { + formattedPercentage = (parseFloat(data.percentage) * 100).toFixed(1); + } else if (data.percent) { + formattedPercentage = (parseFloat(data.percent) * 100).toFixed(1); + } + // Helper for constructing info about the parent item + const getParentInfo = () => { + if (!data.parent) return ''; + + if (data.parent.type === 'album') { + return ` from album "${data.parent.title}"`; + } else if (data.parent.type === 'playlist') { + return ` from playlist "${data.parent.name}" by ${data.parent.owner}`; + } + return ''; + }; + + // Status-based message generation switch (data.status) { case 'queued': - if (data.type === 'album') { - return `Queued album "${data.name}"${artist ? ` by ${artist}` : ''}`; + if (data.type === 'track') { + return `Queued track "${trackName}"${artist ? ` by ${artist}` : ''}${getParentInfo()}`; + } else if (data.type === 'album') { + return `Queued album "${albumTitle}"${artist ? ` by ${artist}` : ''} (${totalTracks || '?'} tracks)`; } else if (data.type === 'playlist') { - return `Queued playlist "${data.name}"${playlistOwner ? ` from ${playlistOwner}` : ''}`; - } else if (data.type === 'track') { - return `Queued track "${trackName}"${artist ? ` by ${artist}` : ''}`; + return `Queued playlist "${playlistName}"${playlistOwner ? ` by ${playlistOwner}` : ''} (${totalTracks || '?'} tracks)`; } - return `Queued ${data.type} "${data.name}"`; + return `Queued ${data.type}`; case 'initializing': - if (data.type === 'playlist') { - return `Initializing playlist "${data.name}"${playlistOwner ? ` from ${playlistOwner}` : ''} with ${totalTracks} tracks...`; - } else if (data.type === 'album') { - return `Initializing album "${data.album || data.name}"${artist ? ` by ${artist}` : ''}...`; - } else if (data.type === 'track') { - return `Initializing track "${trackName}"${artist ? ` by ${artist}` : ''}...`; - } - return `Initializing ${data.type} download...`; + return `Preparing to download...`; case 'processing': - if (data.type === 'track') { - return `Processing track "${trackName}"${artist ? ` by ${artist}` : ''}...`; - } else if (data.type === 'album' && currentTrack && totalTracks) { - return `Processing track ${currentTrack}/${totalTracks}: ${trackName}...`; - } else if (data.type === 'playlist' && currentTrack && totalTracks) { - return `Processing track ${currentTrack}/${totalTracks}: ${trackName}...`; + // Special case: If this is a track that's part of an album/playlist + if (data.type === 'track' && data.parent) { + if (data.parent.type === 'album') { + return `Processing track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} (from album "${data.parent.title}")`; + } else if (data.parent.type === 'playlist') { + return `Processing track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} (from playlist "${data.parent.name}")`; + } } - return `Processing ${data.type} download...`; - - case 'real_time': + + // Regular standalone track if (data.type === 'track') { - return `Track "${trackName}"${artist ? ` by ${artist}` : ''} is ${formattedPercentage}% downloaded...`; - } else if (data.type === 'album' && currentTrack && totalTracks) { - return `Track "${trackName}" (${currentTrack}/${totalTracks}) is ${formattedPercentage}% downloaded...`; - } else if (data.type === 'playlist' && currentTrack && totalTracks) { - return `Track "${trackName}" (${currentTrack}/${totalTracks}) is ${formattedPercentage}% downloaded...`; + return `Processing track "${trackName}"${artist ? ` by ${artist}` : ''}${getParentInfo()}`; + } + // Album download + else if (data.type === 'album') { + // For albums, show current track info if available + if (trackName && artist && currentTrack && totalTracks) { + return `Processing track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist}`; + } else if (currentTrack && totalTracks) { + // If we have track numbers but not names + return `Processing track ${currentTrack} of ${totalTracks} from album "${albumTitle}"`; + } else if (totalTracks) { + return `Processing album "${albumTitle}" (${totalTracks} tracks)`; + } + return `Processing album "${albumTitle}"...`; + } + // Playlist download + else if (data.type === 'playlist') { + // For playlists, show current track info if available + if (trackName && artist && currentTrack && totalTracks) { + return `Processing track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist}`; + } else if (currentTrack && totalTracks) { + // If we have track numbers but not names + return `Processing track ${currentTrack} of ${totalTracks} from playlist "${playlistName}"`; + } else if (totalTracks) { + return `Processing playlist "${playlistName}" (${totalTracks} tracks)`; + } + return `Processing playlist "${playlistName}"...`; + } + return `Processing ${data.type}...`; + + case 'progress': + // Special case: If this is a track that's part of an album/playlist + if (data.type === 'track' && data.parent) { + if (data.parent.type === 'album') { + return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} (from album "${data.parent.title}")`; + } else if (data.parent.type === 'playlist') { + return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} (from playlist "${data.parent.name}")`; + } + } + + // Regular standalone track + if (data.type === 'track') { + return `Downloading track "${trackName}"${artist ? ` by ${artist}` : ''}${getParentInfo()}`; + } + // Album download + else if (data.type === 'album') { + // For albums, show current track info if available + if (trackName && artist && currentTrack && totalTracks) { + return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist}`; + } else if (currentTrack && totalTracks) { + // If we have track numbers but not names + return `Downloading track ${currentTrack} of ${totalTracks} from album "${albumTitle}"`; + } else if (totalTracks) { + return `Downloading album "${albumTitle}" (${totalTracks} tracks)`; + } + return `Downloading album "${albumTitle}"...`; + } + // Playlist download + else if (data.type === 'playlist') { + // For playlists, show current track info if available + if (trackName && artist && currentTrack && totalTracks) { + return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist}`; + } else if (currentTrack && totalTracks) { + // If we have track numbers but not names + return `Downloading track ${currentTrack} of ${totalTracks} from playlist "${playlistName}"`; + } else if (totalTracks) { + return `Downloading playlist "${playlistName}" (${totalTracks} tracks)`; + } + return `Downloading playlist "${playlistName}"...`; } return `Downloading ${data.type}...`; - case 'progress': - if (data.type === 'track') { - return `Downloading track "${trackName}"${artist ? ` by ${artist}` : ''}...`; - } else if (data.type === 'album' && currentTrack && totalTracks) { - return `Downloading track "${trackName}" (${currentTrack}/${totalTracks})...`; - } else if (data.type === 'playlist' && currentTrack && totalTracks) { - return `Downloading track "${trackName}" (${currentTrack}/${totalTracks})...`; + case 'real-time': + case 'real_time': + // Special case: If this is a track that's part of an album/playlist + if (data.type === 'track' && data.parent) { + if (data.parent.type === 'album') { + return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} - ${formattedPercentage}% (from album "${data.parent.title}")`; + } else if (data.parent.type === 'playlist') { + return `Downloading track ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} - ${formattedPercentage}% (from playlist "${data.parent.name}")`; + } + } + + // Regular standalone track + if (data.type === 'track') { + return `Downloading "${trackName}" - ${formattedPercentage}%${getParentInfo()}`; + } + // Album with track info + else if (data.type === 'album' && trackName && artist) { + return `Downloading ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} - ${formattedPercentage}%`; + } + // Playlist with track info + else if (data.type === 'playlist' && trackName && artist) { + return `Downloading ${currentTrack}/${totalTracks}: "${trackName}" by ${artist} - ${formattedPercentage}%`; + } + // Generic with percentage + else { + const itemName = data.type === 'album' ? albumTitle : + (data.type === 'playlist' ? playlistName : data.type); + return `Downloading ${data.type} "${itemName}" - ${formattedPercentage}%`; } - return `Downloading ${data.type}...`; - case 'complete': case 'done': + case 'complete': if (data.type === 'track') { - return `Finished track "${trackName}"${artist ? ` by ${artist}` : ''}`; + return `Downloaded "${trackName}"${artist ? ` by ${artist}` : ''} successfully${getParentInfo()}`; } else if (data.type === 'album') { - return `Finished album "${data.album || data.name}"${artist ? ` by ${artist}` : ''}`; + return `Downloaded album "${albumTitle}"${artist ? ` by ${artist}` : ''} successfully (${totalTracks} tracks)`; } else if (data.type === 'playlist') { - return `Finished playlist "${data.name}"${playlistOwner ? ` from ${playlistOwner}` : ''}`; + return `Downloaded playlist "${playlistName}"${playlistOwner ? ` by ${playlistOwner}` : ''} successfully (${totalTracks} tracks)`; } - return `Finished ${data.type} download`; + return `Downloaded ${data.type} successfully`; + + case 'skipped': + return `${trackName}${artist ? ` by ${artist}` : ''} was skipped: ${data.reason || 'Unknown reason'}`; case 'error': - let errorMsg = `Error: ${data.message || 'Unknown error'}`; - if (data.can_retry !== undefined) { + let errorMsg = `Error: ${data.error || data.message || 'Unknown error'}`; + if (data.retry_count !== undefined) { + errorMsg += ` (Attempt ${data.retry_count}/${this.MAX_RETRIES})`; + } else if (data.can_retry !== undefined) { if (data.can_retry) { errorMsg += ` (Can be retried)`; } else { @@ -801,17 +1093,25 @@ class DownloadQueue { } return errorMsg; - case 'cancelled': - return 'Download cancelled'; - case 'retrying': - if (data.retry_count !== undefined) { - return `Retrying download (attempt ${data.retry_count}/${this.MAX_RETRIES})`; + let retryMsg = 'Retrying'; + if (data.retry_count) { + retryMsg += ` (${data.retry_count}/${this.MAX_RETRIES})`; } - return `Retrying download...`; + if (data.seconds_left) { + retryMsg += ` in ${data.seconds_left}s`; + } + if (data.error) { + retryMsg += `: ${data.error}`; + } + return retryMsg; + + case 'cancelled': + case 'cancel': + return 'Cancelling...'; default: - return data.status; + return data.status || 'Unknown status'; } } @@ -897,7 +1197,7 @@ class DownloadQueue { } try { - // Close any existing SSE connection + // Close any existing polling interval this.clearPollingInterval(queueId); console.log(`Retrying download for ${entry.type} with URL: ${retryUrl}`); @@ -943,7 +1243,7 @@ class DownloadQueue { entry.intervalId = null; } - // Set up a new SSE connection for the retried download + // Set up a new polling interval for the retried download this.setupPollingInterval(queueId); // Delete the old PRG file after a short delay to ensure the new one is properly set up @@ -975,7 +1275,7 @@ class DownloadQueue { for (const queueId in this.queueEntries) { const entry = this.queueEntries[queueId]; // Only start monitoring if the entry is not in a terminal state and is visible - if (!entry.hasEnded && this.isEntryVisible(queueId) && !this.sseConnections[queueId]) { + if (!entry.hasEnded && this.isEntryVisible(queueId) && !this.pollingIntervals[queueId]) { this.setupPollingInterval(queueId); } } @@ -993,18 +1293,10 @@ class DownloadQueue { await this.loadConfig(); - // Build the API URL with only necessary parameters + // Build the API URL with only the URL parameter as it's all that's needed let apiUrl = `/api/${type}/download?url=${encodeURIComponent(url)}`; - // Add name and artist if available for better progress display - if (item.name) { - apiUrl += `&name=${encodeURIComponent(item.name)}`; - } - if (item.artist) { - apiUrl += `&artist=${encodeURIComponent(item.artist)}`; - } - - // For artist downloads, include album_type + // For artist downloads, include album_type as it may still be needed if (type === 'artist' && albumType) { apiUrl += `&album_type=${encodeURIComponent(albumType)}`; } @@ -1097,6 +1389,19 @@ class DownloadQueue { // Handle single-file downloads (tracks, albums, playlists) if (data.prg_file) { console.log(`Adding ${type} with PRG file: ${data.prg_file}`); + + // Store the initial metadata in the cache so it's available + // even before the first status update + this.queueCache[data.prg_file] = { + type, + status: 'initializing', + name: item.name || 'Unknown', + title: item.name || 'Unknown', + artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0].name : ''), + owner: item.owner || (item.owner ? item.owner.display_name : ''), + total_tracks: item.total_tracks || 0 + }; + // Use direct monitoring for all downloads for consistency const queueId = this.addDownload(item, type, data.prg_file, apiUrl, true); @@ -1181,15 +1486,60 @@ class DownloadQueue { // Use the enhanced original request info from the first line const originalRequest = prgData.original_request || {}; + let lastLineData = prgData.last_line || {}; - // Use the explicit display fields if available, or fall back to other fields - const dummyItem = { - 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', - url: originalRequest.url || '', - endpoint: originalRequest.endpoint || '', - download_type: originalRequest.download_type || '' + // First check if this is a track with a parent (part of an album/playlist) + let itemType = lastLineData.type || prgData.display_type || originalRequest.display_type || originalRequest.type || 'unknown'; + let dummyItem = {}; + + // If this is a track with a parent, treat it as the parent type for UI purposes + if (lastLineData.type === 'track' && lastLineData.parent) { + const parent = lastLineData.parent; + + if (parent.type === 'album') { + itemType = 'album'; + dummyItem = { + name: parent.title || 'Unknown Album', + artist: parent.artist || 'Unknown Artist', + type: 'album', + total_tracks: parent.total_tracks || 0, + url: parent.url || '', + // Keep track of the current track info for progress display + current_track: lastLineData.current_track, + total_tracks: parent.total_tracks || lastLineData.total_tracks, + // Store parent info directly in the item + parent: parent + }; + } else if (parent.type === 'playlist') { + itemType = 'playlist'; + dummyItem = { + name: parent.name || 'Unknown Playlist', + owner: parent.owner || 'Unknown Creator', + type: 'playlist', + total_tracks: parent.total_tracks || 0, + url: parent.url || '', + // Keep track of the current track info for progress display + current_track: lastLineData.current_track, + total_tracks: parent.total_tracks || lastLineData.total_tracks, + // Store parent info directly in the item + parent: parent + }; + } + } else { + // Use the explicit display fields if available, or fall back to other fields + dummyItem = { + name: prgData.display_title || originalRequest.display_title || lastLineData.name || lastLineData.song || lastLineData.title || originalRequest.name || prgFile, + artist: prgData.display_artist || originalRequest.display_artist || lastLineData.artist || originalRequest.artist || '', + type: itemType, + url: originalRequest.url || lastLineData.url || '', + endpoint: originalRequest.endpoint || '', + download_type: originalRequest.download_type || '', + // Include any available track info + song: lastLineData.song, + title: lastLineData.title, + total_tracks: lastLineData.total_tracks, + current_track: lastLineData.current_track + }; }; // Check if this is a retry file and get the retry count @@ -1227,18 +1577,39 @@ class DownloadQueue { // Add to download queue const queueId = this.generateQueueId(); - const entry = this.createQueueEntry(dummyItem, dummyItem.type, prgFile, queueId, requestUrl); + const entry = this.createQueueEntry(dummyItem, itemType, prgFile, queueId, requestUrl); entry.retryCount = retryCount; // Set the entry's last status from the PRG file if (prgData.last_line) { entry.lastStatus = prgData.last_line; + // If this is a track that's part of an album/playlist + if (prgData.last_line.parent) { + entry.parentInfo = prgData.last_line.parent; + } + // Make sure to save the status to the cache for persistence this.queueCache[prgFile] = prgData.last_line; // Apply proper status classes this.applyStatusClasses(entry, prgData.last_line); + + // Update log display with current info + const logElement = entry.element.querySelector('.log'); + if (logElement) { + if (prgData.last_line.song && prgData.last_line.artist && + ['progress', 'real-time', 'real_time', 'processing', 'downloading'].includes(prgData.last_line.status)) { + logElement.textContent = `Currently downloading: ${prgData.last_line.song} by ${prgData.last_line.artist}`; + } else if (entry.parentInfo && !['done', 'complete', 'error', 'skipped'].includes(prgData.last_line.status)) { + // Show parent info for non-terminal states + if (entry.parentInfo.type === 'album') { + logElement.textContent = `From album: ${entry.parentInfo.title}`; + } else if (entry.parentInfo.type === 'playlist') { + logElement.textContent = `From playlist: ${entry.parentInfo.name} by ${entry.parentInfo.owner}`; + } + } + } } this.queueEntries[queueId] = entry; @@ -1305,7 +1676,7 @@ class DownloadQueue { return !!this.config.explicitFilter; } - /* Sets up a Server-Sent Events connection for real-time status updates */ + /* Sets up a polling interval for real-time status updates */ setupPollingInterval(queueId) { console.log(`Setting up polling for ${queueId}`); const entry = this.queueEntries[queueId]; @@ -1321,13 +1692,13 @@ class DownloadQueue { // Immediately fetch initial data this.fetchDownloadStatus(queueId); - // Create a polling interval of 1 second + // Create a polling interval of 500ms for more responsive UI updates const intervalId = setInterval(() => { this.fetchDownloadStatus(queueId); - }, 1000); + }, 500); // Store the interval ID for later cleanup - this.sseConnections[queueId] = intervalId; + this.pollingIntervals[queueId] = intervalId; } catch (error) { console.error(`Error creating polling for ${queueId}:`, error); const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); @@ -1353,6 +1724,27 @@ class DownloadQueue { const data = await response.json(); + // If the last_line doesn't have name/artist/title info, add it from our stored item data + if (data.last_line && entry.item) { + if (!data.last_line.name && entry.item.name) { + data.last_line.name = entry.item.name; + } + if (!data.last_line.title && entry.item.name) { + data.last_line.title = entry.item.name; + } + if (!data.last_line.artist && entry.item.artist) { + data.last_line.artist = entry.item.artist; + } else if (!data.last_line.artist && entry.item.artists && entry.item.artists.length > 0) { + data.last_line.artist = entry.item.artists[0].name; + } + if (!data.last_line.owner && entry.item.owner) { + data.last_line.owner = entry.item.owner; + } + if (!data.last_line.total_tracks && entry.item.total_tracks) { + data.last_line.total_tracks = entry.item.total_tracks; + } + } + // Initialize the download type if needed if (data.type && !entry.type) { console.log(`Setting entry type to: ${data.type}`); @@ -1367,8 +1759,20 @@ class DownloadQueue { } } - // Filter the last_line if it doesn't match the entry's type - if (data.last_line && data.last_line.type && entry.type && data.last_line.type !== entry.type) { + // Special handling for track updates that are part of an album/playlist + // Don't filter these out as they contain important track progress info + if (data.last_line && data.last_line.type === 'track' && data.last_line.parent) { + // This is a track update that's part of an album/playlist - keep it + if ((entry.type === 'album' && data.last_line.parent.type === 'album') || + (entry.type === 'playlist' && data.last_line.parent.type === 'playlist')) { + console.log(`Processing track update for ${entry.type} download: ${data.last_line.song}`); + // Continue processing - don't return + } + } + // Only filter out updates that don't match entry type AND don't have a relevant parent + else if (data.last_line && data.last_line.type && entry.type && + data.last_line.type !== entry.type && + (!data.last_line.parent || data.last_line.parent.type !== entry.type)) { console.log(`Skipping status update with type '${data.last_line.type}' for entry with type '${entry.type}'`); return; } @@ -1381,6 +1785,14 @@ class DownloadQueue { console.log(`Terminal state detected: ${data.last_line.status} for ${queueId}`); entry.hasEnded = true; + // For cancelled downloads, clean up immediately + if (data.last_line.status === 'cancelled' || data.last_line.status === 'cancel') { + console.log('Cleaning up cancelled download immediately'); + this.clearPollingInterval(queueId); + this.cleanupEntry(queueId); + return; // No need to process further + } + // Only set up cleanup if this is not an error that we're in the process of retrying // If status is 'error' but the status message contains 'Retrying', don't clean up const isRetrying = entry.isRetrying || @@ -1412,19 +1824,18 @@ class DownloadQueue { } clearPollingInterval(queueId) { - if (this.sseConnections[queueId]) { + if (this.pollingIntervals[queueId]) { console.log(`Stopping polling for ${queueId}`); try { - // Clear the interval instead of closing the SSE connection - clearInterval(this.sseConnections[queueId]); + clearInterval(this.pollingIntervals[queueId]); } catch (error) { console.error(`Error stopping polling for ${queueId}:`, error); } - delete this.sseConnections[queueId]; + delete this.pollingIntervals[queueId]; } } - /* Handle SSE update events */ + /* Handle status updates from the progress API */ handleStatusUpdate(queueId, data) { const entry = this.queueEntries[queueId]; if (!entry) { @@ -1432,75 +1843,74 @@ class DownloadQueue { return; } - // Get status from the appropriate location in the data structure - // For the new polling API, data is structured differently than the SSE events - let status, message, progress; - // Extract the actual status data from the API response const statusData = data.last_line || {}; - // Skip updates where the type doesn't match the entry's type - if (statusData.type && entry.type && statusData.type !== entry.type) { + // Special handling for track status updates that are part of an album/playlist + // We want to keep these for showing the track-by-track progress + if (statusData.type === 'track' && statusData.parent) { + // If this is a track that's part of our album/playlist, keep it + if ((entry.type === 'album' && statusData.parent.type === 'album') || + (entry.type === 'playlist' && statusData.parent.type === 'playlist')) { + console.log(`Processing track status update for ${entry.type}: ${statusData.song}`); + } + } + // Only skip updates where type doesn't match AND there's no relevant parent relationship + else if (statusData.type && entry.type && statusData.type !== entry.type && + (!statusData.parent || statusData.parent.type !== entry.type)) { + console.log(`Skipping mismatched type: update=${statusData.type}, entry=${entry.type}`); return; } - status = statusData.status || data.event || 'unknown'; + // Get primary status + const status = statusData.status || data.event || 'unknown'; - // For new polling API structure - if (data.progress_message) { - message = data.progress_message; - } else if (statusData.message) { - message = statusData.message; - } else { - message = `Status: ${status}`; + // Store the status data for potential retries + entry.lastStatus = statusData; + entry.lastUpdated = Date.now(); + + // Update type if needed - could be more specific now (e.g., from 'album' to 'compilation') + if (statusData.type && statusData.type !== entry.type) { + entry.type = statusData.type; + const typeEl = entry.element.querySelector('.type'); + if (typeEl) { + const displayType = entry.type.charAt(0).toUpperCase() + entry.type.slice(1); + typeEl.textContent = displayType; + typeEl.className = `type ${entry.type}`; + } } - // Update the title with better information if available - // This is crucial for artist discography downloads which initially use generic titles - const titleEl = entry.element.querySelector('.title'); - if (titleEl) { - // Check various data sources for a better title - let betterTitle = null; - - // First check if data has original_request with name - if (data.original_request && data.original_request.name) { - betterTitle = data.original_request.name; - } - // Then check if statusData has album or name - else if (statusData.album) { - betterTitle = statusData.album; - } - else if (statusData.name) { - betterTitle = statusData.name; - } - // Then check display_title from various sources - else if (data.display_title) { - betterTitle = data.display_title; - } - else if (statusData.display_title) { - betterTitle = statusData.display_title; - } - - // If we found a better title and it's different from what we already have - if (betterTitle && betterTitle !== titleEl.textContent && - // Don't replace if current title is more specific than "Artist - Album" - (titleEl.textContent.includes(' - Album') || titleEl.textContent === 'Unknown Album')) { - console.log(`Updating title from "${titleEl.textContent}" to "${betterTitle}"`); - titleEl.textContent = betterTitle; - // Also update the item's name for future reference - entry.item.name = betterTitle; - } + // Update the title and artist with better information if available + this.updateItemMetadata(entry, statusData, data); + + // Generate appropriate user-friendly message + const message = this.getStatusMessage(statusData); + + // Update log message - but only if we're not handling a track update for an album/playlist + // That case is handled separately in updateItemMetadata to ensure we show the right track info + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + if (logElement && !(statusData.type === 'track' && statusData.parent && + (entry.type === 'album' || entry.type === 'playlist'))) { + logElement.textContent = message; } - // Track progress data - if (data.progress_percent) { - progress = data.progress_percent; - } else if (statusData.overall_progress) { - progress = statusData.overall_progress; - } else if (statusData.progress) { - progress = statusData.progress; + // Handle real-time progress data for single track downloads + if (status === 'real-time') { + this.updateRealTimeProgress(entry, statusData); } + // Handle overall progress for albums and playlists + const isMultiTrack = entry.type === 'album' || entry.type === 'playlist'; + if (isMultiTrack) { + this.updateMultiTrackProgress(entry, statusData); + } else { + // For single tracks, update the track progress + this.updateSingleTrackProgress(entry, statusData); + } + + // Apply appropriate status classes + this.applyStatusClasses(entry, status); + // Special handling for error status if (status === 'error') { entry.hasEnded = true; @@ -1541,7 +1951,6 @@ class DownloadQueue { console.log(`Error for ${entry.type} download. Retry URL: ${retryUrl}`); // Get or create the log element - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); if (logElement) { // Always show retry if we have a URL, even if we've reached retry limit const canRetry = !!retryUrl; @@ -1607,44 +2016,481 @@ class DownloadQueue { }, 10000); } } + } + + // Handle terminal states for non-error cases + if (['complete', 'cancel', 'cancelled', 'done', 'skipped'].includes(status)) { + entry.hasEnded = true; + this.handleDownloadCompletion(entry, queueId, statusData); + } + + // Cache the status for potential page reloads + this.queueCache[entry.prgFile] = statusData; + localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); + } + + // Update item metadata (title, artist, etc.) + updateItemMetadata(entry, statusData, data) { + const titleEl = entry.element.querySelector('.title'); + const artistEl = entry.element.querySelector('.artist'); + + if (titleEl) { + // Check various data sources for a better title + let betterTitle = null; - // Update CSS classes for error state - entry.element.classList.remove('queued', 'initializing', 'downloading', 'processing', 'progress'); - entry.element.classList.add('error'); - - // Close SSE connection - this.clearPollingInterval(queueId); - } else { - // For non-error states, update the log element with the latest message - const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); - if (logElement && message) { - logElement.textContent = message; - } else if (logElement) { - // Generate a message if none provided - logElement.textContent = this.getStatusMessage(statusData); + // First check the statusData + if (statusData.song) { + betterTitle = statusData.song; + } else if (statusData.album) { + betterTitle = statusData.album; + } else if (statusData.name) { + betterTitle = statusData.name; + } + // Then check if data has original_request with name + else if (data.original_request && data.original_request.name) { + betterTitle = data.original_request.name; + } + // Then check display_title from various sources + else if (statusData.display_title) { + betterTitle = statusData.display_title; + } else if (data.display_title) { + betterTitle = data.display_title; } - // Set the proper status classes on the list item - this.applyStatusClasses(entry, status); - - // Handle progress indicators - const progressBar = entry.element.querySelector('.progress-bar'); - if (progressBar && typeof progress === 'number') { - progressBar.style.width = `${progress}%`; - progressBar.setAttribute('aria-valuenow', progress); - - if (progress >= 100) { - progressBar.classList.add('bg-success'); - } else { - progressBar.classList.remove('bg-success'); - } + // Update title if we found a better one + if (betterTitle && betterTitle !== titleEl.textContent) { + titleEl.textContent = betterTitle; + // Also update the item's name for future reference + entry.item.name = betterTitle; + } + } + + // Update artist if available + if (artistEl) { + let artist = statusData.artist || data.display_artist || ''; + if (artist && (!artistEl.textContent || artistEl.textContent !== artist)) { + artistEl.textContent = artist; + // Update item data + entry.item.artist = artist; } } } - - /* Close all active SSE connections */ + + // Update real-time progress for track downloads + updateRealTimeProgress(entry, statusData) { + // Get track progress bar + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile); + const timeElapsedEl = entry.element.querySelector('#time-elapsed-' + entry.uniqueId + '-' + entry.prgFile); + + if (trackProgressBar && statusData.progress !== undefined) { + // Update track progress bar + const progress = parseFloat(statusData.progress); + trackProgressBar.style.width = `${progress}%`; + trackProgressBar.setAttribute('aria-valuenow', progress); + + // Add success class when complete + if (progress >= 100) { + trackProgressBar.classList.add('complete'); + } else { + trackProgressBar.classList.remove('complete'); + } + } + + // Display time elapsed if available + if (timeElapsedEl && statusData.time_elapsed !== undefined) { + const seconds = Math.floor(statusData.time_elapsed / 1000); + const formattedTime = seconds < 60 + ? `${seconds}s` + : `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + timeElapsedEl.textContent = formattedTime; + } + } + + // Update progress for single track downloads + updateSingleTrackProgress(entry, statusData) { + // Get track progress bar and other UI elements + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + const titleElement = entry.element.querySelector('.title'); + const artistElement = entry.element.querySelector('.artist'); + + // If this track has a parent, this is actually part of an album/playlist + // We should update the entry type and handle it as a multi-track download + if (statusData.parent && (statusData.parent.type === 'album' || statusData.parent.type === 'playlist')) { + // Store parent info + entry.parentInfo = statusData.parent; + + // Update entry type to match parent type + entry.type = statusData.parent.type; + + // Update UI to reflect the parent type + const typeEl = entry.element.querySelector('.type'); + if (typeEl) { + const displayType = entry.type.charAt(0).toUpperCase() + entry.type.slice(1); + typeEl.textContent = displayType; + typeEl.className = `type ${entry.type}`; + } + + // Update title and subtitle based on parent type + if (statusData.parent.type === 'album') { + if (titleElement) titleElement.textContent = statusData.parent.title || 'Unknown album'; + if (artistElement) artistElement.textContent = statusData.parent.artist || 'Unknown artist'; + } else if (statusData.parent.type === 'playlist') { + if (titleElement) titleElement.textContent = statusData.parent.name || 'Unknown playlist'; + if (artistElement) artistElement.textContent = statusData.parent.owner || 'Unknown creator'; + } + + // Now delegate to the multi-track progress updater + this.updateMultiTrackProgress(entry, statusData); + return; + } + + // For standalone tracks (without parent), update title and subtitle + if (!statusData.parent && statusData.song && titleElement) { + titleElement.textContent = statusData.song; + } + + if (!statusData.parent && statusData.artist && artistElement) { + artistElement.textContent = statusData.artist; + } + + // For individual track downloads, show the parent context if available + if (!['done', 'complete', 'error', 'skipped'].includes(statusData.status)) { + // First check if we have parent data in the current status update + if (statusData.parent && logElement) { + // Store parent info in the entry for persistence across refreshes + entry.parentInfo = statusData.parent; + + let infoText = ''; + if (statusData.parent.type === 'album') { + infoText = `From album: ${statusData.parent.title}`; + } else if (statusData.parent.type === 'playlist') { + infoText = `From playlist: ${statusData.parent.name} by ${statusData.parent.owner}`; + } + + if (infoText) { + logElement.textContent = infoText; + } + } + // If no parent in current update, use stored parent info if available + else if (entry.parentInfo && logElement) { + let infoText = ''; + if (entry.parentInfo.type === 'album') { + infoText = `From album: ${entry.parentInfo.title}`; + } else if (entry.parentInfo.type === 'playlist') { + infoText = `From playlist: ${entry.parentInfo.name} by ${entry.parentInfo.owner}`; + } + + if (infoText) { + logElement.textContent = infoText; + } + } + } + + // Calculate progress based on available data + let progress = 0; + + // Real-time progress for direct track download + if (statusData.status === 'real-time' && statusData.progress !== undefined) { + progress = parseFloat(statusData.progress); + } else if (statusData.percent !== undefined) { + progress = parseFloat(statusData.percent) * 100; + } else if (statusData.percentage !== undefined) { + progress = parseFloat(statusData.percentage) * 100; + } else if (statusData.status === 'done' || statusData.status === 'complete') { + progress = 100; + } else if (statusData.current_track && statusData.total_tracks) { + // If we don't have real-time progress but do have track position + progress = (parseInt(statusData.current_track, 10) / parseInt(statusData.total_tracks, 10)) * 100; + } + + // Update track progress bar if available + if (trackProgressBar) { + // Ensure numeric progress and prevent NaN + const safeProgress = isNaN(progress) ? 0 : Math.max(0, Math.min(100, progress)); + + trackProgressBar.style.width = `${safeProgress}%`; + trackProgressBar.setAttribute('aria-valuenow', safeProgress); + + // Make sure progress bar is visible + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile); + if (trackProgressContainer) { + trackProgressContainer.style.display = 'block'; + } + + // Add success class when complete + if (safeProgress >= 100) { + trackProgressBar.classList.add('complete'); + } else { + trackProgressBar.classList.remove('complete'); + } + } + } + + // Update progress for multi-track downloads (albums and playlists) + updateMultiTrackProgress(entry, statusData) { + // Get progress elements + const progressCounter = document.getElementById(`progress-count-${entry.uniqueId}-${entry.prgFile}`); + const overallProgressBar = document.getElementById(`overall-bar-${entry.uniqueId}-${entry.prgFile}`); + const trackProgressBar = entry.element.querySelector('#track-progress-bar-' + entry.uniqueId + '-' + entry.prgFile); + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); + const titleElement = entry.element.querySelector('.title'); + const artistElement = entry.element.querySelector('.artist'); + + // Initialize track progress variables + let currentTrack = 0; + let totalTracks = 0; + let trackProgress = 0; + + // Handle track-level updates for album/playlist downloads + if (statusData.type === 'track' && statusData.parent && + (entry.type === 'album' || entry.type === 'playlist')) { + console.log('Processing track update for multi-track download:', statusData); + + // Update parent title/artist for album + if (entry.type === 'album' && statusData.parent.type === 'album') { + if (titleElement && statusData.parent.title) { + titleElement.textContent = statusData.parent.title; + } + if (artistElement && statusData.parent.artist) { + artistElement.textContent = statusData.parent.artist; + } + } + // Update parent title/owner for playlist + else if (entry.type === 'playlist' && statusData.parent.type === 'playlist') { + if (titleElement && statusData.parent.name) { + titleElement.textContent = statusData.parent.name; + } + if (artistElement && statusData.parent.owner) { + artistElement.textContent = statusData.parent.owner; + } + } + + // Get current track and total tracks from the status data + if (statusData.current_track !== undefined) { + currentTrack = parseInt(statusData.current_track, 10); + + // Get total tracks - try from statusData first, then from parent + if (statusData.total_tracks !== undefined) { + totalTracks = parseInt(statusData.total_tracks, 10); + } else if (statusData.parent && statusData.parent.total_tracks !== undefined) { + totalTracks = parseInt(statusData.parent.total_tracks, 10); + } + + console.log(`Track info: ${currentTrack}/${totalTracks}`); + } + + // Get track progress for real-time updates + if (statusData.status === 'real-time' && statusData.progress !== undefined) { + trackProgress = parseFloat(statusData.progress); + } + + // Update the track progress counter display + if (progressCounter && totalTracks > 0) { + progressCounter.textContent = `${currentTrack}/${totalTracks}`; + } + + // Update the status message to show current track + if (logElement && statusData.song && statusData.artist) { + let progressInfo = ''; + if (statusData.status === 'real-time' && trackProgress > 0) { + progressInfo = ` - ${trackProgress.toFixed(1)}%`; + } + logElement.textContent = `Currently downloading: ${statusData.song} by ${statusData.artist} (${currentTrack}/${totalTracks}${progressInfo})`; + } + + // Calculate and update the overall progress bar + if (totalTracks > 0) { + let overallProgress = 0; + + if (statusData.status === 'real-time' && trackProgress !== undefined) { + // Use the formula: ((current_track-1)/(total_tracks))+(1/total_tracks*progress) + const completedTracksProgress = (currentTrack - 1) / totalTracks; + const currentTrackContribution = (1 / totalTracks) * (trackProgress / 100); + overallProgress = (completedTracksProgress + currentTrackContribution) * 100; + console.log(`Real-time overall progress: ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks}, Progress: ${trackProgress}%)`); + } else { + // Standard progress calculation based on current track position + overallProgress = (currentTrack / totalTracks) * 100; + console.log(`Standard overall progress: ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks})`); + } + + // Update the progress bar + if (overallProgressBar) { + const safeProgress = Math.max(0, Math.min(100, overallProgress)); + overallProgressBar.style.width = `${safeProgress}%`; + overallProgressBar.setAttribute('aria-valuenow', safeProgress); + + if (safeProgress >= 100) { + overallProgressBar.classList.add('complete'); + } else { + overallProgressBar.classList.remove('complete'); + } + } + + // Update the track-level progress bar + if (trackProgressBar) { + // Make sure progress bar container is visible + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile); + if (trackProgressContainer) { + trackProgressContainer.style.display = 'block'; + } + + if (statusData.status === 'real-time') { + // Real-time progress for the current track + const safeTrackProgress = Math.max(0, Math.min(100, trackProgress)); + trackProgressBar.style.width = `${safeTrackProgress}%`; + trackProgressBar.setAttribute('aria-valuenow', safeTrackProgress); + trackProgressBar.classList.add('real-time'); + + if (safeTrackProgress >= 100) { + trackProgressBar.classList.add('complete'); + } else { + trackProgressBar.classList.remove('complete'); + } + } else { + // Indeterminate progress animation for non-real-time updates + trackProgressBar.classList.add('progress-pulse'); + trackProgressBar.style.width = '100%'; + trackProgressBar.setAttribute('aria-valuenow', 50); + } + } + + // Store progress for potential later use + entry.progress = overallProgress; + } + + return; // Skip the standard handling below + } + + // Standard handling for album/playlist direct updates (not track-level): + // Update title and subtitle based on item type + if (entry.type === 'album') { + if (statusData.title && titleElement) { + titleElement.textContent = statusData.title; + } + if (statusData.artist && artistElement) { + artistElement.textContent = statusData.artist; + } + } else if (entry.type === 'playlist') { + if (statusData.name && titleElement) { + titleElement.textContent = statusData.name; + } + if (statusData.owner && artistElement) { + artistElement.textContent = statusData.owner; + } + } + + // Extract track counting data from status data + if (statusData.current_track && statusData.total_tracks) { + currentTrack = parseInt(statusData.current_track, 10); + totalTracks = parseInt(statusData.total_tracks, 10); + } else if (statusData.parsed_current_track && statusData.parsed_total_tracks) { + currentTrack = parseInt(statusData.parsed_current_track, 10); + totalTracks = parseInt(statusData.parsed_total_tracks, 10); + } else if (statusData.current_track && /^\d+\/\d+$/.test(statusData.current_track)) { + // Parse formats like "1/12" + const parts = statusData.current_track.split('/'); + currentTrack = parseInt(parts[0], 10); + totalTracks = parseInt(parts[1], 10); + } + + // Get track progress for real-time downloads + if (statusData.status === 'real-time' && statusData.progress !== undefined) { + // For real-time downloads, progress comes as a percentage value (0-100) + trackProgress = parseFloat(statusData.progress); + } else if (statusData.percent !== undefined) { + // Handle percent values (0-1) + trackProgress = parseFloat(statusData.percent) * 100; + } else if (statusData.percentage !== undefined) { + // Handle percentage values (0-1) + trackProgress = parseFloat(statusData.percentage) * 100; + } + + // Update progress counter if available + if (progressCounter && totalTracks > 0) { + progressCounter.textContent = `${currentTrack}/${totalTracks}`; + } + + // Calculate overall progress + let overallProgress = 0; + if (totalTracks > 0) { + // If we have an explicit overall_progress, use it + if (statusData.overall_progress !== undefined) { + overallProgress = parseFloat(statusData.overall_progress); + } else if (statusData.status === 'real-time' && trackProgress !== undefined) { + // Calculate based on formula: ((current_track-1)/(total_tracks))+(1/total_tracks*progress) + // This gives a precise calculation for real-time downloads + const completedTracksProgress = (currentTrack - 1) / totalTracks; + const currentTrackContribution = (1 / totalTracks) * (trackProgress / 100); + overallProgress = (completedTracksProgress + currentTrackContribution) * 100; + console.log(`Real-time progress: Track ${currentTrack}/${totalTracks}, Track progress: ${trackProgress}%, Overall: ${overallProgress.toFixed(2)}%`); + } else { + // For non-real-time downloads, show percentage of tracks downloaded + // Using current_track relative to total_tracks + overallProgress = (currentTrack / totalTracks) * 100; + console.log(`Standard progress: Track ${currentTrack}/${totalTracks}, Overall: ${overallProgress.toFixed(2)}%`); + } + + // Update overall progress bar + if (overallProgressBar) { + // Ensure progress is between 0-100 + const safeProgress = Math.max(0, Math.min(100, overallProgress)); + overallProgressBar.style.width = `${safeProgress}%`; + overallProgressBar.setAttribute('aria-valuenow', safeProgress); + + // Add success class when complete + if (safeProgress >= 100) { + overallProgressBar.classList.add('complete'); + } else { + overallProgressBar.classList.remove('complete'); + } + } + + // Update track progress bar for current track in multi-track items + if (trackProgressBar) { + // Make sure progress bar container is visible + const trackProgressContainer = entry.element.querySelector('#track-progress-container-' + entry.uniqueId + '-' + entry.prgFile); + if (trackProgressContainer) { + trackProgressContainer.style.display = 'block'; + } + + if (statusData.status === 'real-time' || statusData.status === 'real_time') { + // For real-time updates, use the track progress for the small green progress bar + // This shows download progress for the current track only + const safeProgress = isNaN(trackProgress) ? 0 : Math.max(0, Math.min(100, trackProgress)); + trackProgressBar.style.width = `${safeProgress}%`; + trackProgressBar.setAttribute('aria-valuenow', safeProgress); + trackProgressBar.classList.add('real-time'); + + if (safeProgress >= 100) { + trackProgressBar.classList.add('complete'); + } else { + trackProgressBar.classList.remove('complete'); + } + } else if (['progress', 'processing'].includes(statusData.status)) { + // For non-real-time progress updates, show an indeterminate-style progress + // by using a pulsing animation via CSS + trackProgressBar.classList.add('progress-pulse'); + trackProgressBar.style.width = '100%'; + trackProgressBar.setAttribute('aria-valuenow', 50); // indicate in-progress + } else { + // For other status updates, use current track position + trackProgressBar.classList.remove('progress-pulse'); + const trackPositionPercent = currentTrack > 0 ? 100 : 0; + trackProgressBar.style.width = `${trackPositionPercent}%`; + trackProgressBar.setAttribute('aria-valuenow', trackPositionPercent); + } + } + + // Store the progress in the entry for potential later use + entry.progress = overallProgress; + } + } + + /* Close all active polling intervals */ clearAllPollingIntervals() { - for (const queueId in this.sseConnections) { + for (const queueId in this.pollingIntervals) { this.clearPollingInterval(queueId); } } From dbbd8889dfa14f29b1d86f9b4ffe7846d2ff97ce Mon Sep 17 00:00:00 2001 From: "architect.in.git" Date: Tue, 22 Apr 2025 21:51:06 -0600 Subject: [PATCH 3/6] i dont have time for this --- routes/utils/album.py | 24 +++++- routes/utils/artist.py | 14 ++- routes/utils/celery_queue_manager.py | 16 ++-- routes/utils/celery_tasks.py | 15 ++-- routes/utils/playlist.py | 24 +++++- routes/utils/track.py | 24 +++++- static/js/queue.js | 123 ++++++++++++++++++++++++++- templates/config.html | 4 +- 8 files changed, 208 insertions(+), 36 deletions(-) diff --git a/routes/utils/album.py b/routes/utils/album.py index bb8ba90..2a13a43 100755 --- a/routes/utils/album.py +++ b/routes/utils/album.py @@ -6,7 +6,6 @@ from deezspot.deezloader import DeeLogin from pathlib import Path def download_album( - service, url, main, fallback=None, @@ -22,8 +21,24 @@ def download_album( progress_callback=None ): try: - # DEBUG: Print parameters - print(f"DEBUG: album.py received - service={service}, main={main}, fallback={fallback}") + # Detect URL source (Spotify or Deezer) from URL + is_spotify_url = 'open.spotify.com' in url.lower() + is_deezer_url = 'deezer.com' in url.lower() + + # Determine service exclusively from URL + if is_spotify_url: + service = 'spotify' + elif is_deezer_url: + service = 'deezer' + else: + # If URL can't be detected, raise an error + error_msg = "Invalid URL: Must be from open.spotify.com or deezer.com" + print(f"ERROR: {error_msg}") + raise ValueError(error_msg) + + print(f"DEBUG: album.py - URL detection: is_spotify_url={is_spotify_url}, is_deezer_url={is_deezer_url}") + print(f"DEBUG: album.py - Service determined from URL: {service}") + print(f"DEBUG: album.py - Credentials: main={main}, fallback={fallback}") # Load Spotify client credentials if available spotify_client_id = None @@ -49,6 +64,8 @@ def download_album( except Exception as e: print(f"Error loading Spotify search credentials: {e}") + # For Spotify URLs: check if fallback is enabled, if so use the fallback logic, + # otherwise download directly from Spotify if service == 'spotify': if fallback: if quality is None: @@ -186,6 +203,7 @@ def download_album( max_retries=max_retries ) print(f"DEBUG: Album download completed successfully using Spotify main") + # For Deezer URLs: download directly from Deezer elif service == 'deezer': if quality is None: quality = 'FLAC' diff --git a/routes/utils/artist.py b/routes/utils/artist.py index 2074e65..470fd1a 100644 --- a/routes/utils/artist.py +++ b/routes/utils/artist.py @@ -87,6 +87,16 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a logger.info(f"Fetching artist info for ID: {artist_id}") + # Detect URL source (only Spotify is supported for artists) + is_spotify_url = 'open.spotify.com' in url.lower() + is_deezer_url = 'deezer.com' in url.lower() + + # Artist functionality only works with Spotify URLs currently + if not is_spotify_url: + error_msg = "Invalid URL: Artist functionality only supports open.spotify.com URLs" + logger.error(error_msg) + raise ValueError(error_msg) + # Get artist info with albums artist_data = get_spotify_info(artist_id, "artist") @@ -152,8 +162,7 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a "name": album_name, "artist": album_artist, "type": "album", - "service": "spotify", - # Add reference to parent artist request if needed + # URL source will be automatically detected in the download functions "parent_artist_url": url, "parent_request_type": "artist" } @@ -162,7 +171,6 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a task_data = { "download_type": "album", "type": "album", # Type for the download task - "service": "spotify", # Default to Spotify since we're using Spotify API "url": album_url, # Important: use the album URL, not artist URL "retry_url": album_url, # Use album URL for retry logic, not artist URL "name": album_name, diff --git a/routes/utils/celery_queue_manager.py b/routes/utils/celery_queue_manager.py index 3317814..010665a 100644 --- a/routes/utils/celery_queue_manager.py +++ b/routes/utils/celery_queue_manager.py @@ -120,9 +120,6 @@ class CeleryDownloadQueueManager: # Extract original request or use empty dict original_request = task.get("orig_request", task.get("original_request", {})) - # Determine service (spotify or deezer) from config or request - service = original_request.get("service", config_params.get("service", "spotify")) - # Debug retry_url if present if "retry_url" in task: logger.debug(f"Task has retry_url: {task['retry_url']}") @@ -133,21 +130,20 @@ class CeleryDownloadQueueManager: "type": task.get("type", download_type), "name": task.get("name", ""), "artist": task.get("artist", ""), - "service": service, "url": task.get("url", ""), # Preserve retry_url if present "retry_url": task.get("retry_url", ""), - # Use config values but allow override from request - "main": original_request.get("main", - config_params['spotify'] if service == 'spotify' else config_params['deezer']), + # Use main account from config + "main": original_request.get("main", config_params['deezer']), + # Set fallback if enabled in config "fallback": original_request.get("fallback", - config_params['spotify'] if config_params['fallback'] and service == 'spotify' else None), + config_params['spotify'] if config_params['fallback'] else None), - "quality": original_request.get("quality", - config_params['spotifyQuality'] if service == 'spotify' else config_params['deezerQuality']), + # Use default quality settings + "quality": original_request.get("quality", config_params['deezerQuality']), "fall_quality": original_request.get("fall_quality", config_params['spotifyQuality']), diff --git a/routes/utils/celery_tasks.py b/routes/utils/celery_tasks.py index c5cf856..ef3416c 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -893,12 +893,11 @@ def download_track(self, **task_data): custom_track_format = task_data.get("custom_track_format", config_params.get("customTrackFormat", "%tracknum%. %music%")) pad_tracks = task_data.get("pad_tracks", config_params.get("tracknum_padding", True)) - # Execute the download + # Execute the download - service is now determined from URL download_track_func( - service=service, url=url, main=main, - fallback=fallback, + fallback=fallback if fallback_enabled else None, quality=quality, fall_quality=fall_quality, real_time=real_time, @@ -961,12 +960,11 @@ def download_album(self, **task_data): custom_track_format = task_data.get("custom_track_format", config_params.get("customTrackFormat", "%tracknum%. %music%")) pad_tracks = task_data.get("pad_tracks", config_params.get("tracknum_padding", True)) - # Execute the download + # Execute the download - service is now determined from URL download_album_func( - service=service, url=url, main=main, - fallback=fallback, + fallback=fallback if fallback_enabled else None, quality=quality, fall_quality=fall_quality, real_time=real_time, @@ -1034,12 +1032,11 @@ def download_playlist(self, **task_data): retry_delay_increase = task_data.get("retry_delay_increase", config_params.get("retry_delay_increase", 5)) max_retries = task_data.get("max_retries", config_params.get("maxRetries", 3)) - # Execute the download + # Execute the download - service is now determined from URL download_playlist_func( - service=service, url=url, main=main, - fallback=fallback, + fallback=fallback if fallback_enabled else None, quality=quality, fall_quality=fall_quality, real_time=real_time, diff --git a/routes/utils/playlist.py b/routes/utils/playlist.py index 45bb4ff..6830460 100755 --- a/routes/utils/playlist.py +++ b/routes/utils/playlist.py @@ -6,7 +6,6 @@ from deezspot.deezloader import DeeLogin from pathlib import Path def download_playlist( - service, url, main, fallback=None, @@ -22,8 +21,24 @@ def download_playlist( progress_callback=None, ): try: - # DEBUG: Print parameters - print(f"DEBUG: playlist.py received - service={service}, main={main}, fallback={fallback}") + # Detect URL source (Spotify or Deezer) from URL + is_spotify_url = 'open.spotify.com' in url.lower() + is_deezer_url = 'deezer.com' in url.lower() + + # Determine service exclusively from URL + if is_spotify_url: + service = 'spotify' + elif is_deezer_url: + service = 'deezer' + else: + # If URL can't be detected, raise an error + error_msg = "Invalid URL: Must be from open.spotify.com or deezer.com" + print(f"ERROR: {error_msg}") + raise ValueError(error_msg) + + print(f"DEBUG: playlist.py - URL detection: is_spotify_url={is_spotify_url}, is_deezer_url={is_deezer_url}") + print(f"DEBUG: playlist.py - Service determined from URL: {service}") + print(f"DEBUG: playlist.py - Credentials: main={main}, fallback={fallback}") # Load Spotify client credentials if available spotify_client_id = None @@ -49,6 +64,8 @@ def download_playlist( except Exception as e: print(f"Error loading Spotify search credentials: {e}") + # For Spotify URLs: check if fallback is enabled, if so use the fallback logic, + # otherwise download directly from Spotify if service == 'spotify': if fallback: if quality is None: @@ -181,6 +198,7 @@ def download_playlist( max_retries=max_retries ) print(f"DEBUG: Playlist download completed successfully using Spotify main") + # For Deezer URLs: download directly from Deezer elif service == 'deezer': if quality is None: quality = 'FLAC' diff --git a/routes/utils/track.py b/routes/utils/track.py index c163c33..ffa84aa 100755 --- a/routes/utils/track.py +++ b/routes/utils/track.py @@ -6,7 +6,6 @@ from deezspot.deezloader import DeeLogin from pathlib import Path def download_track( - service, url, main, fallback=None, @@ -22,8 +21,24 @@ def download_track( progress_callback=None ): try: - # DEBUG: Print parameters - print(f"DEBUG: track.py received - service={service}, main={main}, fallback={fallback}") + # Detect URL source (Spotify or Deezer) from URL + is_spotify_url = 'open.spotify.com' in url.lower() + is_deezer_url = 'deezer.com' in url.lower() + + # Determine service exclusively from URL + if is_spotify_url: + service = 'spotify' + elif is_deezer_url: + service = 'deezer' + else: + # If URL can't be detected, raise an error + error_msg = "Invalid URL: Must be from open.spotify.com or deezer.com" + print(f"ERROR: {error_msg}") + raise ValueError(error_msg) + + print(f"DEBUG: track.py - URL detection: is_spotify_url={is_spotify_url}, is_deezer_url={is_deezer_url}") + print(f"DEBUG: track.py - Service determined from URL: {service}") + print(f"DEBUG: track.py - Credentials: main={main}, fallback={fallback}") # Load Spotify client credentials if available spotify_client_id = None @@ -49,6 +64,8 @@ def download_track( except Exception as e: print(f"Error loading Spotify search credentials: {e}") + # For Spotify URLs: check if fallback is enabled, if so use the fallback logic, + # otherwise download directly from Spotify if service == 'spotify': if fallback: if quality is None: @@ -166,6 +183,7 @@ def download_track( retry_delay_increase=retry_delay_increase, max_retries=max_retries ) + # For Deezer URLs: download directly from Deezer elif service == 'deezer': if quality is None: quality = 'FLAC' diff --git a/static/js/queue.js b/static/js/queue.js index 44987fd..6539145 100644 --- a/static/js/queue.js +++ b/static/js/queue.js @@ -551,6 +551,9 @@ createQueueItem(item, type, prgFile, queueId) {
${defaultMessage}
+ + +
@@ -620,13 +623,80 @@ createQueueItem(item, type, prgFile, queueId) { break; case 'error': entry.element.classList.add('error'); + // Show detailed error information in the error-details container if available + if (entry.lastStatus && entry.element) { + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`); + if (errorDetailsContainer) { + // Format the error details + let errorDetailsHTML = ''; + + // Add error message + errorDetailsHTML += `
${entry.lastStatus.error || entry.lastStatus.message || 'Unknown error'}
`; + + // Add parent information if available + if (entry.lastStatus.parent) { + const parent = entry.lastStatus.parent; + let parentInfo = ''; + + if (parent.type === 'album') { + parentInfo = `
From album: "${parent.title}" by ${parent.artist || 'Unknown artist'}
`; + } else if (parent.type === 'playlist') { + parentInfo = `
From playlist: "${parent.name}" by ${parent.owner || 'Unknown creator'}
`; + } + + if (parentInfo) { + errorDetailsHTML += parentInfo; + } + } + + // Add source URL if available + if (entry.lastStatus.url) { + errorDetailsHTML += ``; + } + + // Add retry button if this error can be retried + if (entry.lastStatus.can_retry !== false && (!entry.retryCount || entry.retryCount < this.MAX_RETRIES)) { + errorDetailsHTML += ``; + } + + // Display the error details + errorDetailsContainer.innerHTML = errorDetailsHTML; + errorDetailsContainer.style.display = 'block'; + + // Add event listener to retry button if present + const retryBtn = errorDetailsContainer.querySelector('.retry-btn'); + if (retryBtn) { + retryBtn.addEventListener('click', (e) => { + const queueId = e.target.getAttribute('data-queueid'); + if (queueId) { + const logElement = entry.element.querySelector('.log'); + this.retryDownload(queueId, logElement); + } + }); + } + } + } break; case 'complete': case 'done': entry.element.classList.add('complete'); + // Hide error details if present + if (entry.element) { + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`); + if (errorDetailsContainer) { + errorDetailsContainer.style.display = 'none'; + } + } break; case 'cancelled': entry.element.classList.add('cancelled'); + // Hide error details if present + if (entry.element) { + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`); + if (errorDetailsContainer) { + errorDetailsContainer.style.display = 'none'; + } + } break; } } @@ -1081,7 +1151,15 @@ createQueueItem(item, type, prgFile, queueId) { return `${trackName}${artist ? ` by ${artist}` : ''} was skipped: ${data.reason || 'Unknown reason'}`; case 'error': + // Enhanced error message handling using the new format let errorMsg = `Error: ${data.error || data.message || 'Unknown error'}`; + + // Add position information for tracks in collections + if (data.current_track && data.total_tracks) { + errorMsg = `Error on track ${data.current_track}/${data.total_tracks}: ${data.error || data.message || 'Unknown error'}`; + } + + // Add retry information if available if (data.retry_count !== undefined) { errorMsg += ` (Attempt ${data.retry_count}/${this.MAX_RETRIES})`; } else if (data.can_retry !== undefined) { @@ -1091,6 +1169,21 @@ createQueueItem(item, type, prgFile, queueId) { errorMsg += ` (Max retries reached)`; } } + + // Add parent information if this is a track with a parent + if (data.type === 'track' && data.parent) { + if (data.parent.type === 'album') { + errorMsg += `\nFrom album: "${data.parent.title}" by ${data.parent.artist || 'Unknown artist'}`; + } else if (data.parent.type === 'playlist') { + errorMsg += `\nFrom playlist: "${data.parent.name}" by ${data.parent.owner || 'Unknown creator'}`; + } + } + + // Add URL for troubleshooting if available + if (data.url) { + errorMsg += `\nSource: ${data.url}`; + } + return errorMsg; case 'retrying': @@ -1168,8 +1261,30 @@ createQueueItem(item, type, prgFile, queueId) { entry.isRetrying = true; logElement.textContent = 'Retrying download...'; + // Determine if we should use parent information for retry + let useParent = false; + let parentType = null; + let parentUrl = null; + + // Check if we have parent information in the lastStatus + if (entry.lastStatus && entry.lastStatus.parent) { + const parent = entry.lastStatus.parent; + if (parent.type && parent.url) { + useParent = true; + parentType = parent.type; + parentUrl = parent.url; + console.log(`Using parent info for retry: ${parentType} with URL: ${parentUrl}`); + } + } + // Find a retry URL from various possible sources const getRetryUrl = () => { + // If using parent, return parent URL + if (useParent && parentUrl) { + return parentUrl; + } + + // Otherwise use the standard fallback options if (entry.requestUrl) return entry.requestUrl; // If we have lastStatus with original_request, check there @@ -1200,10 +1315,12 @@ createQueueItem(item, type, prgFile, queueId) { // Close any existing polling interval this.clearPollingInterval(queueId); - console.log(`Retrying download for ${entry.type} with URL: ${retryUrl}`); + // Determine which type to use for the API endpoint + const apiType = useParent ? parentType : entry.type; + console.log(`Retrying download using type: ${apiType} with URL: ${retryUrl}`); - // Build the API URL based on the entry's type - const apiUrl = `/api/${entry.type}/download?url=${encodeURIComponent(retryUrl)}`; + // Build the API URL based on the determined type + const apiUrl = `/api/${apiType}/download?url=${encodeURIComponent(retryUrl)}`; // Add name and artist if available for better progress display let fullRetryUrl = apiUrl; diff --git a/templates/config.html b/templates/config.html index a15f67b..7cd30b7 100644 --- a/templates/config.html +++ b/templates/config.html @@ -116,7 +116,7 @@ - + @@ -169,7 +169,7 @@ - + From 948e424fdea668ab0f94fdb7459250e3629c1922 Mon Sep 17 00:00:00 2001 From: "architect.in.git" Date: Wed, 23 Apr 2025 09:54:17 -0600 Subject: [PATCH 4/6] Solved error ui handling --- routes/album.py | 5 +- routes/playlist.py | 5 +- routes/prgs.py | 6 +- routes/track.py | 16 ++- static/js/queue.js | 324 +++++++++++++-------------------------------- 5 files changed, 117 insertions(+), 239 deletions(-) diff --git a/routes/album.py b/routes/album.py index 7ff16ed..7ec11e5 100755 --- a/routes/album.py +++ b/routes/album.py @@ -23,12 +23,15 @@ def handle_download(): # Add the task to the queue with only essential parameters # The queue manager will now handle all config parameters + # Include full original request URL in metadata + orig_params = request.args.to_dict() + orig_params["original_url"] = request.url task_id = download_queue_manager.add_task({ "download_type": "album", "url": url, "name": name, "artist": artist, - "orig_request": request.args.to_dict() + "orig_request": orig_params }) return Response( diff --git a/routes/playlist.py b/routes/playlist.py index acad76b..feb7eb8 100755 --- a/routes/playlist.py +++ b/routes/playlist.py @@ -23,12 +23,15 @@ def handle_download(): # Add the task to the queue with only essential parameters # The queue manager will now handle all config parameters + # Include full original request URL in metadata + orig_params = request.args.to_dict() + orig_params["original_url"] = request.url task_id = download_queue_manager.add_task({ "download_type": "playlist", "url": url, "name": name, "artist": artist, - "orig_request": request.args.to_dict() + "orig_request": orig_params }) return Response( diff --git a/routes/prgs.py b/routes/prgs.py index 0291039..cab005f 100755 --- a/routes/prgs.py +++ b/routes/prgs.py @@ -54,6 +54,7 @@ def get_prg_file(task_id): # Prepare the simplified response with just the requested info response = { + "original_url": original_request.get("original_url", ""), "last_line": last_status, "timestamp": time.time(), "task_id": task_id, @@ -121,10 +122,6 @@ def get_prg_file(task_id): 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] @@ -138,6 +135,7 @@ def get_prg_file(task_id): # Return simplified response format return jsonify({ + "original_url": original_request.get("original_url", "") if original_request else "", "last_line": last_line_parsed, "timestamp": time.time(), "task_id": task_id, diff --git a/routes/track.py b/routes/track.py index 7317f43..609a441 100755 --- a/routes/track.py +++ b/routes/track.py @@ -3,6 +3,7 @@ import os import json import traceback from routes.utils.celery_queue_manager import download_queue_manager +from urllib.parse import urlparse # for URL validation track_bp = Blueprint('track', __name__) @@ -16,19 +17,30 @@ def handle_download(): # Validate required parameters if not url: return Response( - json.dumps({"error": "Missing required parameter: url"}), + json.dumps({"error": "Missing required parameter: url", "original_url": url}), + status=400, + mimetype='application/json' + ) + # Validate URL domain + parsed = urlparse(url) + host = parsed.netloc.lower() + if not (host.endswith('deezer.com') or host.endswith('open.spotify.com') or host.endswith('spotify.com')): + return Response( + json.dumps({"error": f"Invalid Link {url} :(", "original_url": url}), status=400, mimetype='application/json' ) # Add the task to the queue with only essential parameters # The queue manager will now handle all config parameters + orig_params = request.args.to_dict() + orig_params["original_url"] = request.url task_id = download_queue_manager.add_task({ "download_type": "track", "url": url, "name": name, "artist": artist, - "orig_request": request.args.to_dict() + "orig_request": orig_params }) return Response( diff --git a/static/js/queue.js b/static/js/queue.js index 6539145..fed2acd 100644 --- a/static/js/queue.js +++ b/static/js/queue.js @@ -284,6 +284,11 @@ class DownloadQueue { entry.requestUrl = `/api/${entry.type}/download?${params.toString()}`; } + // Override requestUrl with server original_url if provided + if (data.original_url) { + entry.requestUrl = data.original_url; + } + // Process the initial status if (data.last_line) { entry.lastStatus = data.last_line; @@ -451,63 +456,8 @@ class DownloadQueue { entry.parentInfo = cachedData.parent; } - // Special handling for error states to restore UI with buttons - if (entry.lastStatus.status === 'error') { - // Hide the cancel button if in error state - const cancelBtn = entry.element.querySelector('.cancel-btn'); - if (cancelBtn) { - cancelBtn.style.display = 'none'; - } - - // Determine if we can retry - const canRetry = entry.retryCount < this.MAX_RETRIES && entry.requestUrl; - - if (canRetry) { - // Create error UI with retry button - logEl.innerHTML = ` -
${this.getStatusMessage(entry.lastStatus)}
-
- - -
- `; - - // Add event listeners - logEl.querySelector('.close-error-btn').addEventListener('click', () => { - if (entry.autoRetryInterval) { - clearInterval(entry.autoRetryInterval); - entry.autoRetryInterval = null; - } - this.cleanupEntry(queueId); - }); - - logEl.querySelector('.retry-btn').addEventListener('click', async () => { - if (entry.autoRetryInterval) { - clearInterval(entry.autoRetryInterval); - entry.autoRetryInterval = null; - } - this.retryDownload(queueId, logEl); - }); - } else { - // Cannot retry - just show error with close button - logEl.innerHTML = ` -
${this.getStatusMessage(entry.lastStatus)}
-
- -
- `; - - logEl.querySelector('.close-error-btn').addEventListener('click', () => { - this.cleanupEntry(queueId); - }); - } - } else { - // For non-error states, just set the message text - logEl.textContent = this.getStatusMessage(entry.lastStatus); - } - - // Apply appropriate CSS classes based on cached status - this.applyStatusClasses(entry, this.queueCache[prgFile]); + // Render status message for cached data + logEl.textContent = this.getStatusMessage(entry.lastStatus); } // Store it in our queue object @@ -623,58 +573,10 @@ createQueueItem(item, type, prgFile, queueId) { break; case 'error': entry.element.classList.add('error'); - // Show detailed error information in the error-details container if available - if (entry.lastStatus && entry.element) { - const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`); - if (errorDetailsContainer) { - // Format the error details - let errorDetailsHTML = ''; - - // Add error message - errorDetailsHTML += `
${entry.lastStatus.error || entry.lastStatus.message || 'Unknown error'}
`; - - // Add parent information if available - if (entry.lastStatus.parent) { - const parent = entry.lastStatus.parent; - let parentInfo = ''; - - if (parent.type === 'album') { - parentInfo = `
From album: "${parent.title}" by ${parent.artist || 'Unknown artist'}
`; - } else if (parent.type === 'playlist') { - parentInfo = `
From playlist: "${parent.name}" by ${parent.owner || 'Unknown creator'}
`; - } - - if (parentInfo) { - errorDetailsHTML += parentInfo; - } - } - - // Add source URL if available - if (entry.lastStatus.url) { - errorDetailsHTML += ``; - } - - // Add retry button if this error can be retried - if (entry.lastStatus.can_retry !== false && (!entry.retryCount || entry.retryCount < this.MAX_RETRIES)) { - errorDetailsHTML += ``; - } - - // Display the error details - errorDetailsContainer.innerHTML = errorDetailsHTML; - errorDetailsContainer.style.display = 'block'; - - // Add event listener to retry button if present - const retryBtn = errorDetailsContainer.querySelector('.retry-btn'); - if (retryBtn) { - retryBtn.addEventListener('click', (e) => { - const queueId = e.target.getAttribute('data-queueid'); - if (queueId) { - const logElement = entry.element.querySelector('.log'); - this.retryDownload(queueId, logElement); - } - }); - } - } + // Hide error-details to prevent duplicate error display + const errorDetailsContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`); + if (errorDetailsContainer) { + errorDetailsContainer.style.display = 'none'; } break; case 'complete': @@ -741,9 +643,7 @@ createQueueItem(item, type, prgFile, queueId) { // Immediately delete from server try { - await fetch(`/api/prgs/delete/${prg}`, { - method: 'DELETE' - }); + await fetch(`/api/prgs/delete/${prg}`, { method: 'DELETE' }); console.log(`Deleted cancelled task from server: ${prg}`); } catch (deleteError) { console.error('Error deleting cancelled task:', deleteError); @@ -1152,11 +1052,11 @@ createQueueItem(item, type, prgFile, queueId) { case 'error': // Enhanced error message handling using the new format - let errorMsg = `Error: ${data.error || data.message || 'Unknown error'}`; + let errorMsg = `Error: ${data.error}`; // Add position information for tracks in collections if (data.current_track && data.total_tracks) { - errorMsg = `Error on track ${data.current_track}/${data.total_tracks}: ${data.error || data.message || 'Unknown error'}`; + errorMsg = `Error on track ${data.current_track}/${data.total_tracks}: ${data.error}`; } // Add retry information if available @@ -1257,6 +1157,11 @@ createQueueItem(item, type, prgFile, queueId) { const entry = this.queueEntries[queueId]; if (!entry) return; + // Hide any existing error-details and restore log for retry + const errContainer = entry.element.querySelector(`#error-details-${entry.uniqueId}-${entry.prgFile}`); + if (errContainer) { errContainer.style.display = 'none'; } + logElement.style.display = ''; + // Mark the entry as retrying to prevent automatic cleanup entry.isRetrying = true; logElement.textContent = 'Retrying download...'; @@ -1279,6 +1184,10 @@ createQueueItem(item, type, prgFile, queueId) { // Find a retry URL from various possible sources const getRetryUrl = () => { + // Prefer full original URL from progress API + if (entry.lastStatus && entry.lastStatus.original_url) { + return entry.lastStatus.original_url; + } // If using parent, return parent URL if (useParent && parentUrl) { return parentUrl; @@ -1299,6 +1208,11 @@ createQueueItem(item, type, prgFile, queueId) { if (entry.lastStatus && entry.lastStatus.url) return entry.lastStatus.url; + // Fallback to stored requestUrl + if (entry.requestUrl) { + return entry.requestUrl; + } + return null; }; @@ -1319,18 +1233,22 @@ createQueueItem(item, type, prgFile, queueId) { const apiType = useParent ? parentType : entry.type; console.log(`Retrying download using type: ${apiType} with URL: ${retryUrl}`); - // Build the API URL based on the determined type - const apiUrl = `/api/${apiType}/download?url=${encodeURIComponent(retryUrl)}`; - - // Add name and artist if available for better progress display - let fullRetryUrl = apiUrl; - if (entry.item && entry.item.name) { - fullRetryUrl += `&name=${encodeURIComponent(entry.item.name)}`; + // Determine request URL: if retryUrl is already a full API URL, use it directly + let fullRetryUrl; + if (retryUrl.startsWith('http')) { + fullRetryUrl = retryUrl; + } else { + const apiUrl = `/api/${apiType}/download?url=${encodeURIComponent(retryUrl)}`; + fullRetryUrl = apiUrl; + // Append metadata if retryUrl is raw resource URL + if (entry.item && entry.item.name) { + fullRetryUrl += `&name=${encodeURIComponent(entry.item.name)}`; + } + if (entry.item && entry.item.artist) { + fullRetryUrl += `&artist=${encodeURIComponent(entry.item.artist)}`; + } } - if (entry.item && entry.item.artist) { - fullRetryUrl += `&artist=${encodeURIComponent(entry.item.artist)}`; - } - + // Use the stored original request URL to create a new download const retryResponse = await fetch(fullRetryUrl); if (!retryResponse.ok) { @@ -1571,10 +1489,10 @@ createQueueItem(item, type, prgFile, queueId) { // Skip prg files that are marked as cancelled, completed, or interrupted if (prgData.last_line && - (prgData.last_line.status === 'cancel' || - prgData.last_line.status === 'cancelled' || - prgData.last_line.status === 'interrupted' || - prgData.last_line.status === 'complete')) { + (prgData.last_line.status === "cancel" || + prgData.last_line.status === "cancelled" || + prgData.last_line.status === "interrupted" || + prgData.last_line.status === "complete")) { // Delete old completed or cancelled PRG files try { await fetch(`/api/prgs/delete/${prgFile}`, { method: 'DELETE' }); @@ -1879,7 +1797,7 @@ createQueueItem(item, type, prgFile, queueId) { // Special handling for track updates that are part of an album/playlist // Don't filter these out as they contain important track progress info if (data.last_line && data.last_line.type === 'track' && data.last_line.parent) { - // This is a track update that's part of an album/playlist - keep it + // This is a track update that's part of our album/playlist - keep it if ((entry.type === 'album' && data.last_line.parent.type === 'album') || (entry.type === 'playlist' && data.last_line.parent.type === 'playlist')) { console.log(`Processing track update for ${entry.type} download: ${data.last_line.song}`); @@ -2006,7 +1924,7 @@ createQueueItem(item, type, prgFile, queueId) { // Update log message - but only if we're not handling a track update for an album/playlist // That case is handled separately in updateItemMetadata to ensure we show the right track info const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); - if (logElement && !(statusData.type === 'track' && statusData.parent && + if (logElement && status !== 'error' && !(statusData.type === 'track' && statusData.parent && (entry.type === 'album' || entry.type === 'playlist'))) { logElement.textContent = message; } @@ -2028,110 +1946,53 @@ createQueueItem(item, type, prgFile, queueId) { // Apply appropriate status classes this.applyStatusClasses(entry, status); - // Special handling for error status + // Special handling for error status based on new API response format if (status === 'error') { entry.hasEnded = true; - - // Hide the cancel button + // Hide cancel button const cancelBtn = entry.element.querySelector('.cancel-btn'); - if (cancelBtn) { - cancelBtn.style.display = 'none'; - } - - // Find a valid URL to use for retry from multiple possible sources - const getRetryUrl = () => { - // Check direct properties first - if (entry.requestUrl) return entry.requestUrl; - if (data.retry_url) return data.retry_url; - if (statusData.retry_url) return statusData.retry_url; - - // Check in original_request object - if (data.original_request) { - if (data.original_request.retry_url) return data.original_request.retry_url; - if (data.original_request.url) return data.original_request.url; - } - - // Last resort - check if there's a URL directly in the data - if (data.url) return data.url; - - return null; - }; - - // Determine if we can retry by finding a valid URL - const retryUrl = getRetryUrl(); - - // Save the retry URL if found + if (cancelBtn) cancelBtn.style.display = 'none'; + + // Extract error details + const errMsg = statusData.error; + const canRetry = Boolean(statusData.can_retry) && statusData.retry_count < statusData.max_retries; + // Determine retry URL + const retryUrl = data.original_url || data.original_request?.url || entry.requestUrl || null; if (retryUrl) { entry.requestUrl = retryUrl; } - - console.log(`Error for ${entry.type} download. Retry URL: ${retryUrl}`); - - // Get or create the log element + + console.log(`Error for ${entry.type} download. Can retry: ${canRetry}. Retry URL: ${retryUrl}`); + + const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); if (logElement) { - // Always show retry if we have a URL, even if we've reached retry limit - const canRetry = !!retryUrl; - - if (canRetry) { - // Create error UI with retry button - logElement.innerHTML = ` -
${message || this.getStatusMessage(statusData)}
-
- - -
- `; - - // Add event listeners - logElement.querySelector('.close-error-btn').addEventListener('click', () => { - if (entry.autoRetryInterval) { - clearInterval(entry.autoRetryInterval); - entry.autoRetryInterval = null; - } + // Build error UI with manual retry always available + logElement.innerHTML = ` +
${errMsg}
+
+ + +
+ `; + // Close handler + logElement.querySelector('.close-error-btn').addEventListener('click', () => { + this.cleanupEntry(queueId); + }); + // Always attach manual retry handler + const retryBtn = logElement.querySelector('.retry-btn'); + retryBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + retryBtn.disabled = true; + retryBtn.innerHTML = ' Retrying...'; + this.retryDownload(queueId, logElement); + }); + // Auto cleanup after 15s + setTimeout(() => { + if (this.queueEntries[queueId]?.hasEnded) { this.cleanupEntry(queueId); - }); - - logElement.querySelector('.retry-btn').addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - - // Replace retry button with loading indicator - const retryBtn = logElement.querySelector('.retry-btn'); - if (retryBtn) { - retryBtn.disabled = true; - retryBtn.innerHTML = ' Retrying...'; - } - - if (entry.autoRetryInterval) { - clearInterval(entry.autoRetryInterval); - entry.autoRetryInterval = null; - } - - this.retryDownload(queueId, logElement); - }); - - // Don't set up automatic cleanup - let retryDownload function handle this - // The automatic cleanup was causing items to disappear when retrying - } else { - // Cannot retry - just show error with close button - logElement.innerHTML = ` -
${message || this.getStatusMessage(statusData)}
-
- -
- `; - - logElement.querySelector('.close-error-btn').addEventListener('click', () => { - this.cleanupEntry(queueId); - }); - - // Set up automatic cleanup after 10 seconds only if not retrying - setTimeout(() => { - if (this.queueEntries[queueId] && this.queueEntries[queueId].hasEnded && !this.queueEntries[queueId].isRetrying) { - this.cleanupEntry(queueId); - } - }, 10000); - } + } + }, 15000); } } @@ -2245,6 +2106,7 @@ createQueueItem(item, type, prgFile, queueId) { if (typeEl) { const displayType = entry.type.charAt(0).toUpperCase() + entry.type.slice(1); typeEl.textContent = displayType; + // Update type class without triggering animation typeEl.className = `type ${entry.type}`; } @@ -2280,9 +2142,9 @@ createQueueItem(item, type, prgFile, queueId) { let infoText = ''; if (statusData.parent.type === 'album') { - infoText = `From album: ${statusData.parent.title}`; + infoText = `From album: "${statusData.parent.title}"`; } else if (statusData.parent.type === 'playlist') { - infoText = `From playlist: ${statusData.parent.name} by ${statusData.parent.owner}`; + infoText = `From playlist: "${statusData.parent.name}" by ${statusData.parent.owner}`; } if (infoText) { @@ -2293,9 +2155,9 @@ createQueueItem(item, type, prgFile, queueId) { else if (entry.parentInfo && logElement) { let infoText = ''; if (entry.parentInfo.type === 'album') { - infoText = `From album: ${entry.parentInfo.title}`; + infoText = `From album: "${entry.parentInfo.title}"`; } else if (entry.parentInfo.type === 'playlist') { - infoText = `From playlist: ${entry.parentInfo.name} by ${entry.parentInfo.owner}`; + infoText = `From playlist: "${entry.parentInfo.name}" by ${entry.parentInfo.owner}`; } if (infoText) { From af2401dd396cc41646ec440db6a1c0cc281776ac Mon Sep 17 00:00:00 2001 From: "architect.in.git" Date: Wed, 23 Apr 2025 12:47:00 -0600 Subject: [PATCH 5/6] lots of shit --- .env | 13 +++ app.py | 16 ++- builds/latest.build.sh | 2 +- docker-compose.yaml | 25 +++-- entrypoint.sh | 5 +- routes/prgs.py | 196 +++++----------------------------- routes/utils/artist.py | 4 + routes/utils/celery_config.py | 7 +- routes/utils/celery_tasks.py | 2 +- static/js/main.js | 15 ++- static/js/queue.js | 46 +++----- 11 files changed, 114 insertions(+), 217 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..1ca1a5d --- /dev/null +++ b/.env @@ -0,0 +1,13 @@ +# Docker Compose environment variables + +# Redis connection (external or internal) +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_DB=0 +REDIS_PASSWORD=CHANGE_ME + +EXPLICIT_FILTER=false # Set to true to filter out explicit content + +PUID=1000 # User ID for the container +PGID=1000 # Group ID for the container +UMASK=0022 # Optional: Sets the default file permissions for newly created files within the container. diff --git a/app.py b/app.py index 786231f..2b87998 100755 --- a/app.py +++ b/app.py @@ -17,6 +17,7 @@ import atexit import sys import redis import socket +from urllib.parse import urlparse # Import Celery configuration and manager from routes.utils.celery_tasks import celery_app @@ -85,11 +86,16 @@ def check_redis_connection(): redis_port = 6379 # default # Parse from REDIS_URL if possible - if REDIS_URL and "://" in REDIS_URL: - parts = REDIS_URL.split("://")[1].split(":") - if len(parts) >= 2: - redis_host = parts[0] - redis_port = int(parts[1].split("/")[0]) + if REDIS_URL: + # parse hostname and port (handles optional auth) + try: + parsed = urlparse(REDIS_URL) + if parsed.hostname: + redis_host = parsed.hostname + if parsed.port: + redis_port = parsed.port + except Exception: + pass # Log Redis connection details logging.info(f"Checking Redis connection to {redis_host}:{redis_port}") diff --git a/builds/latest.build.sh b/builds/latest.build.sh index 104761e..9eb93dd 100755 --- a/builds/latest.build.sh +++ b/builds/latest.build.sh @@ -1 +1 @@ -docker buildx build --push --platform linux/amd64,linux/arm64 --build-arg CACHE_BUST=$(date +%s) --tag cooldockerizer93/spotizerr:latest . +docker buildx build --push --load --platform linux/amd64,linux/arm64 --build-arg CACHE_BUST=$(date +%s) --tag cooldockerizer93/spotizerr:latest . diff --git a/docker-compose.yaml b/docker-compose.yaml index 51cf668..c0449af 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,19 +9,20 @@ services: - ./logs:/app/logs # <-- Volume for persistent logs ports: - 7171:7171 - image: cooldockerizer93/spotizerr + image: cooldockerizer93/spotizerr:dev container_name: spotizerr-app restart: unless-stopped environment: - - PUID=1000 # Replace with your desired user ID | Remove both if you want to run as root (not recommended, might result in unreadable files) - - PGID=1000 # Replace with your desired group ID | The user must have write permissions in the volume mapped to /app/downloads - - UMASK=0022 # Optional: Sets the default file permissions for newly created files within the container. - - REDIS_HOST=redis - - REDIS_PORT=6379 - - REDIS_DB=0 - - REDIS_URL=redis://redis:6379/0 - - REDIS_BACKEND=redis://redis:6379/0 - - EXPLICIT_FILTER=false # Set to true to filter out explicit content + - PUID=${PUID} # Replace with your desired user ID | Remove both if you want to run as root (not recommended, might result in unreadable files) + - PGID=${PGID} # Replace with your desired group ID | The user must have write permissions in the volume mapped to /app/downloads + - UMASK=${UMASK} # Optional: Sets the default file permissions for newly created files within the container. + - REDIS_HOST=${REDIS_HOST} + - REDIS_PORT=${REDIS_PORT} + - REDIS_DB=${REDIS_DB} + - REDIS_PASSWORD=${REDIS_PASSWORD} # Optional, Redis AUTH password. Leave empty if not using authentication + - REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB} + - REDIS_BACKEND=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB} + - EXPLICIT_FILTER=${EXPLICIT_FILTER} # Set to true to filter out explicit content depends_on: - redis @@ -29,9 +30,11 @@ services: image: redis:alpine container_name: spotizerr-redis restart: unless-stopped + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD} volumes: - redis-data:/data - command: redis-server --appendonly yes + command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes volumes: redis-data: diff --git a/entrypoint.sh b/entrypoint.sh index b380d4b..3c79907 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -52,7 +52,10 @@ else # Ensure proper permissions for all app directories echo "Setting permissions for /app directories..." - chown -R "${USER_NAME}:${GROUP_NAME}" /app/downloads /app/config /app/creds /app/logs || true + chown -R "${USER_NAME}:${GROUP_NAME}" /app/downloads /app/config /app/creds /app/logs /app/cache || true + # Ensure Spotipy cache file exists and is writable + touch /app/.cache || true + chown "${USER_NAME}:${GROUP_NAME}" /app/.cache || true # Run as specified user echo "Starting application as ${USER_NAME}..." diff --git a/routes/prgs.py b/routes/prgs.py index cab005f..6190622 100755 --- a/routes/prgs.py +++ b/routes/prgs.py @@ -20,8 +20,7 @@ logger = logging.getLogger(__name__) prgs_bp = Blueprint('prgs', __name__, url_prefix='/api/prgs') -# The old path for PRG files (keeping for backward compatibility during transition) -PRGS_DIR = os.path.join(os.getcwd(), 'prgs') +# (Old .prg file system removed. Using new task system only.) @prgs_bp.route('/', methods=['GET']) def get_prg_file(task_id): @@ -35,116 +34,21 @@ def get_prg_file(task_id): Args: task_id: Either a task UUID from Celery or a PRG filename from the old system """ - try: - # First check if this is a task ID in the new system - task_info = get_task_info(task_id) - - if task_info: - # This is a task ID in the new system - original_request = task_info.get("original_request", {}) - - # Get the latest status update for this task - last_status = get_last_task_status(task_id) - logger.debug(f"API: Got last_status for {task_id}: {json.dumps(last_status) if last_status else None}") - - # Get all status updates for debugging - all_statuses = get_task_status(task_id) - status_count = len(all_statuses) - logger.debug(f"API: Task {task_id} has {status_count} status updates") - - # Prepare the simplified response with just the requested info - response = { - "original_url": original_request.get("original_url", ""), - "last_line": last_status, - "timestamp": time.time(), - "task_id": task_id, - "status_count": status_count - } - - return jsonify(response) - - # If not found in new system, try the old PRG file system - # Security check to prevent path traversal attacks. - if '..' in task_id or '/' in task_id: - abort(400, "Invalid file request") - - filepath = os.path.join(PRGS_DIR, task_id) - - with open(filepath, 'r') as f: - content = f.read() - lines = content.splitlines() - - # If the file is empty, return default values with simplified format. - if not lines: - return jsonify({ - "last_line": None, - "timestamp": time.time(), - "task_id": task_id, - "status_count": 0 - }) - - # 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 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 = "" - - # Get the last line from the file. - last_line_raw = lines[-1] - try: - last_line_parsed = json.loads(last_line_raw) - except Exception: - last_line_parsed = last_line_raw # Fallback to raw string if JSON parsing fails. - - # Calculate status_count for old PRG files (number of lines in the file) - status_count = len(lines) - - # Return simplified response format - return jsonify({ - "original_url": original_request.get("original_url", "") if original_request else "", - "last_line": last_line_parsed, - "timestamp": time.time(), - "task_id": task_id, - "status_count": status_count - }) - except FileNotFoundError: - abort(404, "Task or file not found") - except Exception as e: - abort(500, f"An error occurred: {e}") + # Only support new task IDs + task_info = get_task_info(task_id) + if not task_info: + abort(404, "Task not found") + original_request = task_info.get("original_request", {}) + last_status = get_last_task_status(task_id) + status_count = len(get_task_status(task_id)) + response = { + "original_url": original_request.get("original_url", ""), + "last_line": last_status, + "timestamp": time.time(), + "task_id": task_id, + "status_count": status_count + } + return jsonify(response) @prgs_bp.route('/delete/', methods=['DELETE']) @@ -156,42 +60,15 @@ def delete_prg_file(task_id): Args: task_id: Either a task UUID from Celery or a PRG filename from the old system """ - try: - # First try to delete from Redis if it's a task ID - task_info = get_task_info(task_id) - - if task_info: - # This is a task ID in the new system - we should cancel it first - # if it's still running, then clear its data from Redis - cancel_result = cancel_task(task_id) - - # Use Redis connection to delete the task data - from routes.utils.celery_tasks import redis_client - - # Delete task info and status - redis_client.delete(f"task:{task_id}:info") - redis_client.delete(f"task:{task_id}:status") - - return {'message': f'Task {task_id} deleted successfully'}, 200 - - # If not found in Redis, try the old PRG file system - # Security checks to prevent path traversal and ensure correct file type. - if '..' in task_id or '/' in task_id: - abort(400, "Invalid file request") - if not task_id.endswith('.prg'): - abort(400, "Only .prg files can be deleted") - - filepath = os.path.join(PRGS_DIR, task_id) - - if not os.path.isfile(filepath): - abort(404, "File not found") - - os.remove(filepath) - return {'message': f'File {task_id} deleted successfully'}, 200 - except FileNotFoundError: - abort(404, "Task or file not found") - except Exception as e: - abort(500, f"An error occurred: {e}") + # Only support new task IDs + task_info = get_task_info(task_id) + if not task_info: + abort(404, "Task not found") + cancel_task(task_id) + from routes.utils.celery_tasks import redis_client + redis_client.delete(f"task:{task_id}:info") + redis_client.delete(f"task:{task_id}:status") + return {'message': f'Task {task_id} deleted successfully'}, 200 @prgs_bp.route('/list', methods=['GET']) @@ -200,25 +77,10 @@ def list_prg_files(): Retrieve a list of all tasks in the system. Combines results from both the old PRG file system and the new task ID based system. """ - try: - # Get tasks from the new system - tasks = get_all_tasks() - task_ids = [task["task_id"] for task in tasks] - - # Get PRG files from the old system - prg_files = [] - if os.path.isdir(PRGS_DIR): - with os.scandir(PRGS_DIR) as entries: - for entry in entries: - if entry.is_file() and entry.name.endswith('.prg'): - prg_files.append(entry.name) - - # Combine both lists - all_ids = task_ids + prg_files - - return jsonify(all_ids) - except Exception as e: - abort(500, f"An error occurred: {e}") + # List only new system tasks + tasks = get_all_tasks() + task_ids = [task["task_id"] for task in tasks] + return jsonify(task_ids) @prgs_bp.route('/retry/', methods=['POST']) diff --git a/routes/utils/artist.py b/routes/utils/artist.py index 470fd1a..6510b0d 100644 --- a/routes/utils/artist.py +++ b/routes/utils/artist.py @@ -3,6 +3,7 @@ import traceback from pathlib import Path import os import logging +from flask import Blueprint, Response, request, url_for from routes.utils.celery_queue_manager import download_queue_manager, get_config_params from routes.utils.get_info import get_spotify_info @@ -167,6 +168,9 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a "parent_request_type": "artist" } + # Include original download URL for this album task + album_request_args["original_url"] = url_for('album.handle_download', url=album_url, _external=True) + # Create task for this album task_data = { "download_type": "album", diff --git a/routes/utils/celery_config.py b/routes/utils/celery_config.py index 00a19d1..f455ae6 100644 --- a/routes/utils/celery_config.py +++ b/routes/utils/celery_config.py @@ -10,7 +10,12 @@ logger = logging.getLogger(__name__) REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') REDIS_PORT = os.getenv('REDIS_PORT', '6379') REDIS_DB = os.getenv('REDIS_DB', '0') -REDIS_URL = os.getenv('REDIS_URL', f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}") +# Optional Redis password +REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', '') +# Build default URL with password if provided +_password_part = f":{REDIS_PASSWORD}@" if REDIS_PASSWORD else "" +default_redis_url = f"redis://{_password_part}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" +REDIS_URL = os.getenv('REDIS_URL', default_redis_url) REDIS_BACKEND = os.getenv('REDIS_BACKEND', REDIS_URL) # Log Redis connection details diff --git a/routes/utils/celery_tasks.py b/routes/utils/celery_tasks.py index ef3416c..c671328 100644 --- a/routes/utils/celery_tasks.py +++ b/routes/utils/celery_tasks.py @@ -12,7 +12,7 @@ from celery.exceptions import Retry logger = logging.getLogger(__name__) # Setup Redis and Celery -from routes.utils.celery_config import REDIS_URL, REDIS_BACKEND, get_config_params +from routes.utils.celery_config import REDIS_URL, REDIS_BACKEND, REDIS_PASSWORD, get_config_params # Initialize Celery app celery_app = Celery('download_tasks', diff --git a/static/js/main.js b/static/js/main.js index 6b5af92..b553718 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -42,6 +42,18 @@ document.addEventListener('DOMContentLoaded', function() { }); } + // Restore last search type if no URL override + const savedType = localStorage.getItem('lastSearchType'); + if (savedType && ['track','album','playlist','artist'].includes(savedType)) { + searchType.value = savedType; + } + // Save last selection on change + if (searchType) { + searchType.addEventListener('change', () => { + localStorage.setItem('lastSearchType', searchType.value); + }); + } + // Check for URL parameters const urlParams = new URLSearchParams(window.location.search); const query = urlParams.get('q'); @@ -341,7 +353,8 @@ document.addEventListener('DOMContentLoaded', function() { * Extracts details from a Spotify URL */ function getSpotifyResourceDetails(url) { - const regex = /spotify\.com\/(track|album|playlist|artist)\/([a-zA-Z0-9]+)/; + // Allow optional path segments (e.g. intl-fr) before resource type + const regex = /spotify\.com\/(?:[^\/]+\/)??(track|album|playlist|artist)\/([a-zA-Z0-9]+)/i; const match = url.match(regex); if (match) { diff --git a/static/js/queue.js b/static/js/queue.js index fed2acd..d082674 100644 --- a/static/js/queue.js +++ b/static/js/queue.js @@ -131,13 +131,14 @@ class DownloadQueue { fetch(`/api/${entry.type}/download/cancel?prg_file=${entry.prgFile}`) .then(response => response.json()) .then(data => { - if (data.status === "cancel") { + // API returns status 'cancelled' when cancellation succeeds + if (data.status === "cancelled" || data.status === "cancel") { entry.hasEnded = true; if (entry.intervalId) { clearInterval(entry.intervalId); entry.intervalId = null; } - // Clean up immediately + // Remove the entry as soon as the API confirms cancellation this.cleanupEntry(queueId); } }) @@ -624,7 +625,8 @@ createQueueItem(item, type, prgFile, queueId) { // First cancel the download const response = await fetch(`/api/${type}/download/cancel?prg_file=${prg}`); const data = await response.json(); - if (data.status === "cancel") { + // API returns status 'cancelled' when cancellation succeeds + if (data.status === "cancelled" || data.status === "cancel") { if (entry) { entry.hasEnded = true; @@ -641,14 +643,6 @@ createQueueItem(item, type, prgFile, queueId) { this.queueCache[prg] = { status: "cancelled" }; localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); - // Immediately delete from server - try { - await fetch(`/api/prgs/delete/${prg}`, { method: 'DELETE' }); - console.log(`Deleted cancelled task from server: ${prg}`); - } catch (deleteError) { - console.error('Error deleting cancelled task:', deleteError); - } - // Immediately remove the item from the UI this.cleanupEntry(queueid); } @@ -1639,9 +1633,9 @@ createQueueItem(item, type, prgFile, queueId) { } else if (entry.parentInfo && !['done', 'complete', 'error', 'skipped'].includes(prgData.last_line.status)) { // Show parent info for non-terminal states if (entry.parentInfo.type === 'album') { - logElement.textContent = `From album: ${entry.parentInfo.title}`; + logElement.textContent = `From album: "${entry.parentInfo.title}"`; } else if (entry.parentInfo.type === 'playlist') { - logElement.textContent = `From playlist: ${entry.parentInfo.name} by ${entry.parentInfo.owner}`; + logElement.textContent = `From playlist: "${entry.parentInfo.name}" by ${entry.parentInfo.owner}`; } } } @@ -2281,19 +2275,17 @@ createQueueItem(item, type, prgFile, queueId) { // Calculate and update the overall progress bar if (totalTracks > 0) { let overallProgress = 0; - - if (statusData.status === 'real-time' && trackProgress !== undefined) { - // Use the formula: ((current_track-1)/(total_tracks))+(1/total_tracks*progress) + // Always compute overall based on trackProgress if available, using album/playlist real-time formula + if (trackProgress !== undefined) { const completedTracksProgress = (currentTrack - 1) / totalTracks; const currentTrackContribution = (1 / totalTracks) * (trackProgress / 100); overallProgress = (completedTracksProgress + currentTrackContribution) * 100; - console.log(`Real-time overall progress: ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks}, Progress: ${trackProgress}%)`); + console.log(`Overall progress: ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks}, Progress: ${trackProgress}%)`); } else { - // Standard progress calculation based on current track position overallProgress = (currentTrack / totalTracks) * 100; - console.log(`Standard overall progress: ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks})`); + console.log(`Overall progress (non-real-time): ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks})`); } - + // Update the progress bar if (overallProgressBar) { const safeProgress = Math.max(0, Math.min(100, overallProgress)); @@ -2394,21 +2386,17 @@ createQueueItem(item, type, prgFile, queueId) { // Calculate overall progress let overallProgress = 0; if (totalTracks > 0) { - // If we have an explicit overall_progress, use it + // Use explicit overall_progress if provided if (statusData.overall_progress !== undefined) { overallProgress = parseFloat(statusData.overall_progress); - } else if (statusData.status === 'real-time' && trackProgress !== undefined) { - // Calculate based on formula: ((current_track-1)/(total_tracks))+(1/total_tracks*progress) - // This gives a precise calculation for real-time downloads + } else if (trackProgress !== undefined) { + // For both real-time and standard multi-track downloads, use same formula const completedTracksProgress = (currentTrack - 1) / totalTracks; const currentTrackContribution = (1 / totalTracks) * (trackProgress / 100); overallProgress = (completedTracksProgress + currentTrackContribution) * 100; - console.log(`Real-time progress: Track ${currentTrack}/${totalTracks}, Track progress: ${trackProgress}%, Overall: ${overallProgress.toFixed(2)}%`); + console.log(`Progress: Track ${currentTrack}/${totalTracks}, Track progress: ${trackProgress}%, Overall: ${overallProgress.toFixed(2)}%`); } else { - // For non-real-time downloads, show percentage of tracks downloaded - // Using current_track relative to total_tracks - overallProgress = (currentTrack / totalTracks) * 100; - console.log(`Standard progress: Track ${currentTrack}/${totalTracks}, Overall: ${overallProgress.toFixed(2)}%`); + overallProgress = 0; } // Update overall progress bar From e1a63633fd675275bf12a11f35f5e89b611e339f Mon Sep 17 00:00:00 2001 From: "architect.in.git" Date: Wed, 23 Apr 2025 13:00:14 -0600 Subject: [PATCH 6/6] Added .env to the setup instructions --- README.md | 25 ++++++++++++++++++++++--- builds/latest.build.sh | 2 +- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a45e7cd..dca46fe 100755 --- a/README.md +++ b/README.md @@ -49,9 +49,13 @@ Music downloader which combines the best of two worlds: Spotify's catalog and De mkdir spotizerr && cd spotizerr ``` -2. Copy `docker-compose.yml` from this repo - -3. Launch container: +2. Copy the `.env` file from this repo and update all variables (e.g. Redis credentials, PUID/PGID, UMASK). +3. Copy `docker-compose.yml` from this repo. +4. Create required directories: +```bash +mkdir -p creds config downloads logs cache +``` +5. Launch containers: ```bash docker compose up -d ``` @@ -234,6 +238,21 @@ Copy that value and paste it into the correspondant setting in Spotizerr - Track number padding (01. Track or 1. Track) - Adjust retry parameters (max attempts, delay, delay increase) +### Environment Variables + +Define your variables in the `.env` file in the project root: +```dotenv +REDIS_HOST=redis # Redis host name +REDIS_PORT=6379 # Redis port number +REDIS_DB=0 # Redis DB index +REDIS_PASSWORD=CHANGE_ME # Redis AUTH password +EXPLICIT_FILTER=false # Filter explicit content +PUID=1000 # Container user ID +PGID=1000 # Container group ID +UMASK=0022 # Default file permission mask +SPOTIPY_CACHE_PATH=/app/cache/.cache # Spotify token cache path +``` + ## Troubleshooting **Common Issues**: diff --git a/builds/latest.build.sh b/builds/latest.build.sh index 9eb93dd..104761e 100755 --- a/builds/latest.build.sh +++ b/builds/latest.build.sh @@ -1 +1 @@ -docker buildx build --push --load --platform linux/amd64,linux/arm64 --build-arg CACHE_BUST=$(date +%s) --tag cooldockerizer93/spotizerr:latest . +docker buildx build --push --platform linux/amd64,linux/arm64 --build-arg CACHE_BUST=$(date +%s) --tag cooldockerizer93/spotizerr:latest .