// --- MODIFIED: Custom URLSearchParams class that does not encode anything --- class CustomURLSearchParams { constructor() { this.params = {}; } append(key, value) { this.params[key] = value; } toString() { return Object.entries(this.params) .map(([key, value]) => `${key}=${value}`) .join('&'); } } // --- END MODIFIED --- class DownloadQueue { constructor() { // Constants read from the server config this.MAX_RETRIES = 3; // Default max retries this.RETRY_DELAY = 5; // Default retry delay in seconds this.RETRY_DELAY_INCREASE = 5; // Default retry delay increase in seconds // Cache for queue items this.queueCache = {}; // Queue entry objects this.queueEntries = {}; // Polling intervals for progress tracking this.pollingIntervals = {}; // DOM elements cache this.elements = {}; // Event handlers this.eventHandlers = {}; // Configuration this.config = null; // Load the saved visible count (or default to 10) const storedVisibleCount = localStorage.getItem("downloadQueueVisibleCount"); this.visibleCount = storedVisibleCount ? parseInt(storedVisibleCount, 10) : 10; // Load the cached status info (object keyed by prgFile) this.queueCache = JSON.parse(localStorage.getItem("downloadQueueCache") || "{}"); // Wait for initDOM to complete before setting up event listeners and loading existing PRG files. this.initDOM().then(() => { this.initEventListeners(); this.loadExistingPrgFiles(); }); } /* DOM Management */ async initDOM() { // New HTML structure for the download queue. const queueHTML = ` `; document.body.insertAdjacentHTML('beforeend', queueHTML); // Load initial config from the server. await this.loadConfig(); // Override the server value with locally persisted queue visibility (if present). const storedVisible = localStorage.getItem("downloadQueueVisible"); if (storedVisible !== null) { this.config.downloadQueueVisible = storedVisible === "true"; } const queueSidebar = document.getElementById('downloadQueue'); queueSidebar.hidden = !this.config.downloadQueueVisible; queueSidebar.classList.toggle('active', this.config.downloadQueueVisible); // Initialize the queue icon based on sidebar visibility const queueIcon = document.getElementById('queueIcon'); if (queueIcon) { if (this.config.downloadQueueVisible) { queueIcon.innerHTML = '×'; queueIcon.setAttribute('aria-expanded', 'true'); queueIcon.classList.add('queue-icon-active'); // Add red tint class } else { queueIcon.innerHTML = 'Queue Icon'; queueIcon.setAttribute('aria-expanded', 'false'); queueIcon.classList.remove('queue-icon-active'); // Remove red tint class } } } /* Event Handling */ initEventListeners() { // Toggle queue visibility via Escape key. document.addEventListener('keydown', async (e) => { const queueSidebar = document.getElementById('downloadQueue'); if (e.key === 'Escape' && queueSidebar.classList.contains('active')) { await this.toggleVisibility(); } }); // "Cancel all" button. const cancelAllBtn = document.getElementById('cancelAllBtn'); if (cancelAllBtn) { cancelAllBtn.addEventListener('click', () => { for (const queueId in this.queueEntries) { const entry = this.queueEntries[queueId]; 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 => { // 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; } // Remove the entry as soon as the API confirms cancellation this.cleanupEntry(queueId); } }) .catch(error => console.error('Cancel error:', error)); } } this.clearAllPollingIntervals(); }); } // Close all SSE connections when the page is about to unload window.addEventListener('beforeunload', () => { this.clearAllPollingIntervals(); }); } /* Public API */ async toggleVisibility(force) { const queueSidebar = document.getElementById('downloadQueue'); // If force is provided, use that value, otherwise toggle the current state const isVisible = force !== undefined ? force : !queueSidebar.classList.contains('active'); queueSidebar.classList.toggle('active', isVisible); queueSidebar.hidden = !isVisible; // Update the queue icon to show X when visible or queue icon when hidden const queueIcon = document.getElementById('queueIcon'); if (queueIcon) { if (isVisible) { // Replace the image with an X and add red tint queueIcon.innerHTML = '×'; queueIcon.setAttribute('aria-expanded', 'true'); queueIcon.classList.add('queue-icon-active'); // Add red tint class } else { // Restore the original queue icon and remove red tint queueIcon.innerHTML = 'Queue Icon'; queueIcon.setAttribute('aria-expanded', 'false'); queueIcon.classList.remove('queue-icon-active'); // Remove red tint class } } // Persist the state locally so it survives refreshes. localStorage.setItem("downloadQueueVisible", isVisible); try { await this.loadConfig(); const updatedConfig = { ...this.config, downloadQueueVisible: isVisible }; await this.saveConfig(updatedConfig); this.dispatchEvent('queueVisibilityChanged', { visible: isVisible }); } catch (error) { console.error('Failed to save queue visibility:', error); // Revert UI if save failed. queueSidebar.classList.toggle('active', !isVisible); queueSidebar.hidden = isVisible; // Also revert the icon back if (queueIcon) { if (!isVisible) { queueIcon.innerHTML = '×'; queueIcon.setAttribute('aria-expanded', 'true'); queueIcon.classList.add('queue-icon-active'); // Add red tint class } else { queueIcon.innerHTML = 'Queue Icon'; queueIcon.setAttribute('aria-expanded', 'false'); queueIcon.classList.remove('queue-icon-active'); // Remove red tint class } } this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible }); this.showError('Failed to save queue visibility'); } } showError(message) { const errorDiv = document.createElement('div'); errorDiv.className = 'queue-error'; errorDiv.textContent = message; document.getElementById('queueItems').prepend(errorDiv); setTimeout(() => errorDiv.remove(), 3000); } /** * Adds a new download entry. */ addDownload(item, type, prgFile, requestUrl = null, startMonitoring = false) { const queueId = this.generateQueueId(); const entry = this.createQueueEntry(item, type, prgFile, queueId, requestUrl); this.queueEntries[queueId] = entry; // Re-render and update which entries are processed. this.updateQueueOrder(); // Start monitoring if explicitly requested, regardless of visibility if (startMonitoring) { this.startDownloadStatusMonitoring(queueId); } this.dispatchEvent('downloadAdded', { queueId, item, type }); return queueId; // Return the queueId so callers can reference it } /* Start processing the entry. Removed visibility check to ensure all entries are monitored. */ async startDownloadStatusMonitoring(queueId) { const entry = this.queueEntries[queueId]; if (!entry || entry.hasEnded) 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) { const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); if (logElement) { logElement.textContent = "Initializing download..."; } } console.log(`Starting monitoring for ${entry.type} with PRG file: ${entry.prgFile}`); // For backward compatibility, first try to get initial status from the REST API try { const response = await fetch(`/api/prgs/${entry.prgFile}`); if (response.ok) { const data = await response.json(); // Update entry type if available if (data.type) { entry.type = data.type; // Update type display if element exists const typeElement = entry.element.querySelector('.type'); if (typeElement) { typeElement.textContent = data.type.charAt(0).toUpperCase() + data.type.slice(1); typeElement.className = `type ${data.type}`; } } // Update request URL if available if (!entry.requestUrl && data.original_request) { const params = new CustomURLSearchParams(); for (const key in data.original_request) { params.append(key, data.original_request[key]); } entry.requestUrl = `/api/${entry.type}/download?${params.toString()}`; } // 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; entry.lastUpdated = Date.now(); entry.status = data.last_line.status; // Update status message without recreating the element const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); if (logElement) { const statusMessage = this.getStatusMessage(data.last_line); logElement.textContent = statusMessage; } // Apply appropriate CSS classes based on status this.applyStatusClasses(entry, 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 polling if (['error', 'complete', 'cancel', 'cancelled', 'done'].includes(data.last_line.status)) { entry.hasEnded = true; this.handleDownloadCompletion(entry, queueId, data.last_line); return; } } } } catch (error) { console.error('Initial status check failed:', error); } // Set up polling interval for real-time updates this.setupPollingInterval(queueId); } /* Helper Methods */ generateQueueId() { return Date.now().toString() + Math.random().toString(36).substr(2, 9); } /** * Creates a new queue entry. It checks localStorage for any cached info. */ createQueueEntry(item, type, prgFile, queueId, requestUrl) { console.log(`Creating queue entry with initial type: ${type}`); // 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, prgFile, requestUrl, // for potential retry element: this.createQueueItem(item, type, prgFile, queueId), 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, uniqueId: queueId, retryCount: 0, autoRetryInterval: null, isNew: true, // Add flag to track if this is a new entry status: 'initializing', 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 (cachedData) { entry.lastStatus = cachedData; const logEl = entry.element.querySelector('.log'); // Store parent information if available if (cachedData.parent) { entry.parentInfo = cachedData.parent; } // Render status message for cached data logEl.textContent = this.getStatusMessage(entry.lastStatus); } // Store it in our queue object this.queueEntries[queueId] = entry; return entry; } /** * 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}
${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) { // If no element, nothing to do if (!entry.element) return; // Remove all status classes first entry.element.classList.remove( 'queued', 'initializing', 'downloading', 'processing', 'error', 'complete', 'cancelled', 'progress' ); // Handle various status types switch (status) { case 'queued': entry.element.classList.add('queued'); break; case 'initializing': entry.element.classList.add('initializing'); break; case 'processing': case 'downloading': entry.element.classList.add('processing'); break; case 'progress': case 'track_progress': case 'real_time': entry.element.classList.add('progress'); break; case 'error': entry.element.classList.add('error'); // 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': 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; } } async handleCancelDownload(e) { const btn = e.target.closest('button'); 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(); // API returns status 'cancelled' when cancellation succeeds if (data.status === "cancelled" || data.status === "cancel") { if (entry) { entry.hasEnded = true; // Close any active connections this.clearPollingInterval(queueid); if (entry.intervalId) { clearInterval(entry.intervalId); entry.intervalId = null; } // Mark as cancelled in the cache to prevent re-loading on page refresh entry.status = "cancelled"; this.queueCache[prg] = { status: "cancelled" }; localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); // Immediately remove the item from the UI this.cleanupEntry(queueid); } } } catch (error) { console.error('Cancel error:', error); } } /* Reorders the queue display, updates the total count, and handles "Show more" */ updateQueueOrder() { const container = document.getElementById('queueItems'); const footer = document.getElementById('queueFooter'); const entries = Object.values(this.queueEntries); // Sorting: errors/canceled first (group 0), ongoing next (group 1), queued last (group 2, sorted by position). entries.sort((a, b) => { const getGroup = (entry) => { if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) { return 0; } else if (entry.lastStatus && entry.lastStatus.status === "queued") { return 2; } else { return 1; } }; const groupA = getGroup(a); const groupB = getGroup(b); if (groupA !== groupB) { return groupA - groupB; } else { if (groupA === 2) { const posA = a.lastStatus && a.lastStatus.position ? a.lastStatus.position : Infinity; const posB = b.lastStatus && b.lastStatus.position ? b.lastStatus.position : Infinity; return posA - posB; } return a.lastUpdated - b.lastUpdated; } }); // Update the header with just the total count document.getElementById('queueTotalCount').textContent = entries.length; // Remove subtitle with detailed stats if it exists const subtitleEl = document.getElementById('queueSubtitle'); if (subtitleEl) { subtitleEl.remove(); } // Only recreate the container content if really needed const visibleEntries = entries.slice(0, this.visibleCount); // Handle empty state if (entries.length === 0) { container.innerHTML = `
Empty queue

Your download queue is empty

`; } else { // Get currently visible items const visibleItems = Array.from(container.children).filter(el => el.classList.contains('queue-item')); // Update container more efficiently if (visibleItems.length === 0) { // No items in container, append all visible entries container.innerHTML = ''; // Clear any empty state visibleEntries.forEach(entry => { // We no longer automatically start monitoring here // Monitoring is now explicitly started by the methods that create downloads container.appendChild(entry.element); }); } else { // Container already has items, update more efficiently // Create a map of current DOM elements by queue ID const existingElementMap = {}; visibleItems.forEach(el => { const queueId = el.querySelector('.cancel-btn')?.dataset.queueid; if (queueId) existingElementMap[queueId] = el; }); // Clear container to re-add in correct order container.innerHTML = ''; // Add visible entries in correct order visibleEntries.forEach(entry => { // We no longer automatically start monitoring here container.appendChild(entry.element); // Mark the entry as not new anymore entry.isNew = false; }); } } // We no longer start or stop monitoring based on visibility changes here // This allows the explicit monitoring control from the download methods // Update footer footer.innerHTML = ''; if (entries.length > this.visibleCount) { const remaining = entries.length - this.visibleCount; const showMoreBtn = document.createElement('button'); showMoreBtn.textContent = `Show ${remaining} more`; showMoreBtn.addEventListener('click', () => { this.visibleCount += 10; localStorage.setItem("downloadQueueVisibleCount", this.visibleCount); this.updateQueueOrder(); }); footer.appendChild(showMoreBtn); } } /* Checks if an entry is visible in the queue display. */ isEntryVisible(queueId) { const entries = Object.values(this.queueEntries); entries.sort((a, b) => { const getGroup = (entry) => { if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) { return 0; } else if (entry.lastStatus && entry.lastStatus.status === "queued") { return 2; } else { return 1; } }; const groupA = getGroup(a); const groupB = getGroup(b); if (groupA !== groupB) { return groupA - groupB; } else { if (groupA === 2) { const posA = a.lastStatus && a.lastStatus.position ? a.lastStatus.position : Infinity; const posB = b.lastStatus && b.lastStatus.position ? b.lastStatus.position : Infinity; return posA - posB; } return a.lastUpdated - b.lastUpdated; } }); const index = entries.findIndex(e => e.uniqueId === queueId); return index >= 0 && index < this.visibleCount; } async cleanupEntry(queueId) { const entry = this.queueEntries[queueId]; if (entry) { // Close any polling interval this.clearPollingInterval(queueId); // Clean up any intervals if (entry.intervalId) { clearInterval(entry.intervalId); } if (entry.autoRetryInterval) { clearInterval(entry.autoRetryInterval); } // Remove from the DOM entry.element.remove(); // Delete from in-memory queue delete this.queueEntries[queueId]; // Remove the cached info if (this.queueCache[entry.prgFile]) { delete this.queueCache[entry.prgFile]; localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); } // Delete the entry from the server try { const response = await fetch(`/api/prgs/delete/${entry.prgFile}`, { method: 'DELETE' }); if (response.ok) { console.log(`Successfully deleted task ${entry.prgFile} from server`); } else { console.warn(`Failed to delete task ${entry.prgFile}: ${response.status} ${response.statusText}`); } } catch (error) { console.error(`Error deleting task ${entry.prgFile}:`, error); } // Update the queue display this.updateQueueOrder(); } } /* Event Dispatching */ dispatchEvent(name, detail) { document.dispatchEvent(new CustomEvent(name, { detail })); } /* 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.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 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 === '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 "${playlistName}"${playlistOwner ? ` by ${playlistOwner}` : ''} (${totalTracks || '?'} tracks)`; } return `Queued ${data.type}`; case 'initializing': return `Preparing to download...`; case 'processing': // 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}")`; } } // Regular standalone track if (data.type === 'track') { 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 '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}%`; } case 'done': case 'complete': if (data.type === 'track') { return `Downloaded "${trackName}"${artist ? ` by ${artist}` : ''} successfully${getParentInfo()}`; } else if (data.type === 'album') { return `Downloaded album "${albumTitle}"${artist ? ` by ${artist}` : ''} successfully (${totalTracks} tracks)`; } else if (data.type === 'playlist') { return `Downloaded playlist "${playlistName}"${playlistOwner ? ` by ${playlistOwner}` : ''} successfully (${totalTracks} tracks)`; } return `Downloaded ${data.type} successfully`; case 'skipped': 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}`; // 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}`; } // Add retry information if available 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 { 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': let retryMsg = 'Retrying'; if (data.retry_count) { retryMsg += ` (${data.retry_count}/${this.MAX_RETRIES})`; } 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 || 'Unknown status'; } } /* New Methods to Handle Terminal State, Inactivity and Auto-Retry */ handleDownloadCompletion(entry, queueId, progress) { // Mark the entry as ended entry.hasEnded = true; // Update progress bar if available if (typeof progress === 'number') { const progressBar = entry.element.querySelector('.progress-bar'); if (progressBar) { progressBar.style.width = '100%'; progressBar.setAttribute('aria-valuenow', 100); progressBar.classList.add('bg-success'); } } // Stop polling this.clearPollingInterval(queueId); // Use 10 seconds cleanup delay for all states including errors const cleanupDelay = 10000; // Clean up after the appropriate delay setTimeout(() => { this.cleanupEntry(queueId); }, cleanupDelay); } handleInactivity(entry, queueId, logElement) { if (entry.lastStatus && entry.lastStatus.status === 'queued') { if (logElement) { logElement.textContent = this.getStatusMessage(entry.lastStatus); } return; } const now = Date.now(); if (now - entry.lastUpdated > 300000) { const progress = { status: 'error', message: 'Inactivity timeout' }; this.handleDownloadCompletion(entry, queueId, progress); } else { if (logElement) { logElement.textContent = this.getStatusMessage(entry.lastStatus); } } } async retryDownload(queueId, logElement) { 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...'; // 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 = () => { // 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; } // Otherwise use the standard fallback options if (entry.requestUrl) return entry.requestUrl; // If we have lastStatus with original_request, check there if (entry.lastStatus && entry.lastStatus.original_request) { if (entry.lastStatus.original_request.retry_url) return entry.lastStatus.original_request.retry_url; if (entry.lastStatus.original_request.url) return entry.lastStatus.original_request.url; } // Check if there's a URL directly in the lastStatus if (entry.lastStatus && entry.lastStatus.url) return entry.lastStatus.url; // Fallback to stored requestUrl if (entry.requestUrl) { return entry.requestUrl; } return null; }; const retryUrl = getRetryUrl(); // 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; } try { // Close any existing polling interval this.clearPollingInterval(queueId); // 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}`); // 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)}`; } } // Use the stored original request URL to create a new download const retryResponse = await fetch(fullRetryUrl); if (!retryResponse.ok) { throw new Error(`Server returned ${retryResponse.status}`); } const retryData = await retryResponse.json(); if (retryData.prg_file) { // Store the old PRG file for cleanup const oldPrgFile = entry.prgFile; // Update the entry with the new PRG file const logEl = entry.element.querySelector('.log'); logEl.id = `log-${entry.uniqueId}-${retryData.prg_file}`; entry.prgFile = retryData.prg_file; entry.lastStatus = null; entry.hasEnded = false; entry.lastUpdated = Date.now(); entry.retryCount = (entry.retryCount || 0) + 1; entry.statusCheckFailures = 0; // Reset failure counter logEl.textContent = 'Retry initiated...'; // Make sure any existing interval is cleared if (entry.intervalId) { clearInterval(entry.intervalId); entry.intervalId = null; } // 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 if (oldPrgFile) { setTimeout(async () => { try { await fetch(`/api/prgs/delete/${oldPrgFile}`, { method: 'DELETE' }); console.log(`Cleaned up old PRG file: ${oldPrgFile}`); } catch (deleteError) { console.error('Error deleting old PRG file:', deleteError); } }, 2000); // Wait 2 seconds before deleting the old file } } 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 } } /** * Start monitoring for all active entries in the queue that are visible */ startMonitoringActiveEntries() { 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.pollingIntervals[queueId]) { this.setupPollingInterval(queueId); } } } /** * Centralized download method for all content types. * This method replaces the individual startTrackDownload, startAlbumDownload, etc. methods. * It will be called by all the other JS files. */ async download(url, type, item, albumType = null) { if (!url) { throw new Error('Missing URL for download'); } await this.loadConfig(); // Build the API URL with only the URL parameter as it's all that's needed let apiUrl = `/api/${type}/download?url=${encodeURIComponent(url)}`; // For artist downloads, include album_type as it may still be needed if (type === 'artist' && albumType) { apiUrl += `&album_type=${encodeURIComponent(albumType)}`; } try { // Show a loading indicator if (document.getElementById('queueIcon')) { document.getElementById('queueIcon').classList.add('queue-icon-active'); } const response = await fetch(apiUrl); if (!response.ok) { throw new Error(`Server returned ${response.status}`); } const data = await response.json(); // Handle artist downloads which return multiple album tasks if (type === 'artist') { // Check for new API response format if (data.task_ids && Array.isArray(data.task_ids)) { console.log(`Queued artist discography with ${data.task_ids.length} albums`); // Make queue visible to show progress this.toggleVisibility(true); // Create entries directly from task IDs and start monitoring them const queueIds = []; for (const taskId of data.task_ids) { console.log(`Adding album task with ID: ${taskId}`); // Create an album item with better display information const albumItem = { name: `${item.name || 'Artist'} - Album (loading...)`, artist: item.name || 'Unknown artist', type: 'album' }; // Use improved addDownload with forced monitoring const queueId = this.addDownload(albumItem, 'album', taskId, apiUrl, true); queueIds.push(queueId); } return queueIds; } // Check for older API response format else if (data.album_prg_files && Array.isArray(data.album_prg_files)) { console.log(`Queued artist discography with ${data.album_prg_files.length} albums (old format)`); // Make queue visible to show progress this.toggleVisibility(true); // Add each album to the download queue separately with forced monitoring const queueIds = []; data.album_prg_files.forEach(prgFile => { console.log(`Adding album with PRG file: ${prgFile}`); // Create an album item with better display information const albumItem = { name: `${item.name || 'Artist'} - Album (loading...)`, artist: item.name || 'Unknown artist', type: 'album' }; // Use improved addDownload with forced monitoring const queueId = this.addDownload(albumItem, 'album', prgFile, apiUrl, true); queueIds.push(queueId); }); return queueIds; } // Handle any other response format for artist downloads else { console.log(`Queued artist discography with unknown format:`, data); // Make queue visible this.toggleVisibility(true); // Just load existing PRG files as a fallback await this.loadExistingPrgFiles(); // Force start monitoring for all loaded entries for (const queueId in this.queueEntries) { const entry = this.queueEntries[queueId]; if (!entry.hasEnded) { this.startDownloadStatusMonitoring(queueId); } } return data; } } // 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); // Make queue visible to show progress if not already visible if (!this.config.downloadQueueVisible) { this.toggleVisibility(true); } return queueId; } else { throw new Error('Invalid response format from server'); } } catch (error) { this.dispatchEvent('downloadError', { error, item }); throw error; } } /** * Loads existing PRG files from the /api/prgs/list endpoint and adds them as queue entries. */ async loadExistingPrgFiles() { try { // Clear existing queue entries first to avoid duplicates when refreshing for (const queueId in this.queueEntries) { const entry = this.queueEntries[queueId]; // Close any active connections this.clearPollingInterval(queueId); // Don't remove the entry from DOM - we'll rebuild it entirely delete this.queueEntries[queueId]; } const response = await fetch('/api/prgs/list'); const prgFiles = await response.json(); // Sort filenames by the numeric portion (assumes format "type_number.prg"). prgFiles.sort((a, b) => { const numA = parseInt(a.split('_')[1]); const numB = parseInt(b.split('_')[1]); return numA - numB; }); // Iterate through each PRG file and add it as a dummy queue entry. for (const prgFile of prgFiles) { try { const prgResponse = await fetch(`/api/prgs/${prgFile}`); if (!prgResponse.ok) continue; const prgData = await prgResponse.json(); // 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")) { // Delete old completed or cancelled PRG files try { await fetch(`/api/prgs/delete/${prgFile}`, { method: 'DELETE' }); console.log(`Cleaned up old PRG file: ${prgFile}`); } catch (error) { console.error(`Failed to delete completed/cancelled PRG file ${prgFile}:`, error); } continue; } // Check cached status - if we marked it cancelled locally, delete it and skip const cachedStatus = this.queueCache[prgFile]; if (cachedStatus && (cachedStatus.status === 'cancelled' || cachedStatus.status === 'cancel' || cachedStatus.status === 'interrupted' || cachedStatus.status === 'complete')) { try { await fetch(`/api/prgs/delete/${prgFile}`, { method: 'DELETE' }); console.log(`Cleaned up cached cancelled PRG file: ${prgFile}`); } catch (error) { console.error(`Failed to delete cached cancelled PRG file ${prgFile}:`, error); } continue; } // Use the enhanced original request info from the first line const originalRequest = prgData.original_request || {}; let lastLineData = prgData.last_line || {}; // 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 let retryCount = 0; if (prgFile.includes('_retry')) { const retryMatch = prgFile.match(/_retry(\d+)/); if (retryMatch && retryMatch[1]) { retryCount = parseInt(retryMatch[1], 10); } else if (prgData.last_line && prgData.last_line.retry_count) { retryCount = prgData.last_line.retry_count; } } else if (prgData.last_line && prgData.last_line.retry_count) { retryCount = prgData.last_line.retry_count; } // Build a potential requestUrl from the original information let requestUrl = null; if (dummyItem.endpoint && dummyItem.url) { const params = new CustomURLSearchParams(); params.append('url', dummyItem.url); if (dummyItem.name) params.append('name', dummyItem.name); if (dummyItem.artist) params.append('artist', dummyItem.artist); // Add any other parameters from the original request for (const [key, value] of Object.entries(originalRequest)) { if (!['url', 'name', 'artist', 'type', 'endpoint', 'download_type', 'display_title', 'display_type', 'display_artist', 'service'].includes(key)) { params.append(key, value); } } requestUrl = `${dummyItem.endpoint}?${params.toString()}`; } // Add to download queue const queueId = this.generateQueueId(); 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; } catch (error) { console.error("Error fetching details for", prgFile, error); } } // Save updated cache to localStorage localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); // After adding all entries, update the queue this.updateQueueOrder(); // Start monitoring for all active entries that are visible // This is the key change to ensure continued status updates after page refresh this.startMonitoringActiveEntries(); } catch (error) { console.error("Error loading existing PRG files:", error); } } async loadConfig() { try { const response = await fetch('/api/config'); if (!response.ok) throw new Error('Failed to fetch config'); this.config = await response.json(); // Update our retry constants from the server config if (this.config.maxRetries !== undefined) { this.MAX_RETRIES = this.config.maxRetries; } if (this.config.retryDelaySeconds !== undefined) { this.RETRY_DELAY = this.config.retryDelaySeconds; } if (this.config.retry_delay_increase !== undefined) { this.RETRY_DELAY_INCREASE = this.config.retry_delay_increase; } console.log(`Loaded retry settings from config: max=${this.MAX_RETRIES}, delay=${this.RETRY_DELAY}, increase=${this.RETRY_DELAY_INCREASE}`); } catch (error) { console.error('Error loading config:', error); this.config = {}; } } async saveConfig(updatedConfig) { try { const response = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatedConfig) }); if (!response.ok) throw new Error('Failed to save config'); this.config = await response.json(); } catch (error) { console.error('Error saving config:', error); throw error; } } // Add a method to check if explicit filter is enabled isExplicitFilterEnabled() { return !!this.config.explicitFilter; } /* Sets up a polling interval for real-time status updates */ setupPollingInterval(queueId) { console.log(`Setting up polling for ${queueId}`); const entry = this.queueEntries[queueId]; if (!entry || !entry.prgFile) { console.warn(`No entry or prgFile for ${queueId}`); return; } // Close any existing connection this.clearPollingInterval(queueId); try { // Immediately fetch initial data this.fetchDownloadStatus(queueId); // Create a polling interval of 500ms for more responsive UI updates const intervalId = setInterval(() => { this.fetchDownloadStatus(queueId); }, 500); // Store the interval ID for later cleanup this.pollingIntervals[queueId] = intervalId; } catch (error) { console.error(`Error creating polling for ${queueId}:`, error); const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); if (logElement) { logElement.textContent = `Error with download: ${error.message}`; entry.element.classList.add('error'); } } } async fetchDownloadStatus(queueId) { const entry = this.queueEntries[queueId]; if (!entry || !entry.prgFile) { console.warn(`No entry or prgFile for ${queueId}`); return; } try { const response = await fetch(`/api/prgs/${entry.prgFile}`); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } 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}`); entry.type = data.type; // Update type display if element exists const typeElement = entry.element.querySelector('.type'); if (typeElement) { typeElement.textContent = data.type.charAt(0).toUpperCase() + data.type.slice(1); // Update type class without triggering animation typeElement.className = `type ${data.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 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}`); // 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; } // Process the update this.handleStatusUpdate(queueId, data); // Handle terminal states if (data.last_line && ['complete', 'error', 'cancelled', 'done'].includes(data.last_line.status)) { 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 || (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) { console.error(`Error fetching status for ${queueId}:`, error); // Show error in log const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); if (logElement) { logElement.textContent = `Error updating status: ${error.message}`; } } } clearPollingInterval(queueId) { if (this.pollingIntervals[queueId]) { console.log(`Stopping polling for ${queueId}`); try { clearInterval(this.pollingIntervals[queueId]); } catch (error) { console.error(`Error stopping polling for ${queueId}:`, error); } delete this.pollingIntervals[queueId]; } } /* Handle status updates from the progress API */ handleStatusUpdate(queueId, data) { const entry = this.queueEntries[queueId]; if (!entry) { console.warn(`No entry for ${queueId}`); return; } // Extract the actual status data from the API response const statusData = data.last_line || {}; // 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; } // Get primary status const status = statusData.status || data.event || 'unknown'; // 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 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 && status !== 'error' && !(statusData.type === 'track' && statusData.parent && (entry.type === 'album' || entry.type === 'playlist'))) { logElement.textContent = message; } // 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 based on new API response format if (status === 'error') { entry.hasEnded = true; // Hide cancel button const cancelBtn = entry.element.querySelector('.cancel-btn'); 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. Can retry: ${canRetry}. Retry URL: ${retryUrl}`); const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); if (logElement) { // 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); } }, 15000); } } // 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; // 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; } // 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; } } } // 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; // Update type class without triggering animation 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; // 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(`Overall progress: ${overallProgress.toFixed(2)}% (Track ${currentTrack}/${totalTracks}, Progress: ${trackProgress}%)`); } else { overallProgress = (currentTrack / totalTracks) * 100; 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)); 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) { // Use explicit overall_progress if provided if (statusData.overall_progress !== undefined) { overallProgress = parseFloat(statusData.overall_progress); } 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(`Progress: Track ${currentTrack}/${totalTracks}, Track progress: ${trackProgress}%, Overall: ${overallProgress.toFixed(2)}%`); } else { overallProgress = 0; } // 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.pollingIntervals) { this.clearPollingInterval(queueId); } } } // Singleton instance export const downloadQueue = new DownloadQueue();