diff --git a/web_server.py b/web_server.py index 682b821c..02d2b942 100644 --- a/web_server.py +++ b/web_server.py @@ -592,6 +592,81 @@ def start_download(): print(f"Download error: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/downloads/status') +def get_download_status(): + """ + Sophisticated download status fetching that matches GUI's update_download_status method. + Fetches raw transfer data directly from slskd and flattens it like the GUI. + """ + if not soulseek_client: + return jsonify({"transfers": []}) + + try: + # Fetch raw transfers data exactly like GUI's StatusProcessingWorker + transfers_data = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads')) + + if not transfers_data: + return jsonify({"transfers": []}) + + # Flatten the nested data structure exactly like the GUI + all_transfers = [] + for user_data in transfers_data: + if 'directories' in user_data: + for directory in user_data['directories']: + if 'files' in directory: + for file_info in directory['files']: + # Add username to each file object for convenience + file_info['username'] = user_data.get('username', 'Unknown') + all_transfers.append(file_info) + + return jsonify({"transfers": all_transfers}) + + except Exception as e: + print(f"Error fetching download status: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/downloads/cancel', methods=['POST']) +def cancel_download(): + """ + Cancel a specific download transfer, matching GUI functionality. + """ + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "No data provided."}), 400 + + download_id = data.get('download_id') + username = data.get('username') + + if not all([download_id, username]): + return jsonify({"success": False, "error": "Missing download_id or username."}), 400 + + try: + # Call the same client method the GUI uses + success = asyncio.run(soulseek_client.cancel_download(download_id, username, remove=True)) + if success: + return jsonify({"success": True, "message": "Download cancelled."}) + else: + return jsonify({"success": False, "error": "Failed to cancel download via slskd."}), 500 + except Exception as e: + print(f"Error cancelling download: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/downloads/clear-finished', methods=['POST']) +def clear_finished_downloads(): + """ + Clear all terminal (completed, cancelled, failed) downloads from slskd. + """ + try: + # This single client call handles clearing everything that is no longer active + success = asyncio.run(soulseek_client.clear_all_completed_downloads()) + if success: + return jsonify({"success": True, "message": "Finished downloads cleared."}) + else: + return jsonify({"success": False, "error": "Backend failed to clear downloads."}), 500 + except Exception as e: + print(f"Error clearing finished downloads: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/api/artists') def get_artists(): # Placeholder: returns mock artist data diff --git a/webui/static/script.js b/webui/static/script.js index c3287a4f..31bd6bd1 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -96,18 +96,26 @@ async function loadPageData(pageId) { try { switch (pageId) { case 'dashboard': + // Stop download polling when leaving downloads page + stopDownloadPolling(); await loadDashboardData(); break; case 'sync': + // Stop download polling when leaving downloads page + stopDownloadPolling(); await loadSyncData(); break; case 'downloads': await loadDownloadsData(); break; case 'artists': + // Stop download polling when leaving downloads page + stopDownloadPolling(); await loadArtistsData(); break; case 'settings': + // Stop download polling when leaving downloads page + stopDownloadPolling(); await loadSettingsData(); break; } @@ -921,6 +929,12 @@ async function loadSyncData() { } } +// Download tracking state management - matching GUI functionality +let activeDownloads = {}; +let finishedDownloads = {}; +let downloadStatusInterval = null; +let isDownloadPollingActive = false; + async function loadDownloadsData() { // Downloads page loads search results dynamically console.log('Downloads page loaded'); @@ -928,6 +942,7 @@ async function loadDownloadsData() { // Connect downloads search button const searchButton = document.getElementById('downloads-search-btn'); const searchInput = document.getElementById('downloads-search-input'); + const clearButton = document.querySelector('.controls-panel__clear-btn'); if (searchButton && searchInput) { searchButton.addEventListener('click', performDownloadsSearch); @@ -935,6 +950,264 @@ async function loadDownloadsData() { if (e.key === 'Enter') performDownloadsSearch(); }); } + + if (clearButton) { + clearButton.addEventListener('click', clearFinishedDownloads); + } + + // Start sophisticated polling system (1-second interval like GUI) + startDownloadPolling(); + + // Initialize tab management + initializeDownloadTabs(); +} + +function startDownloadPolling() { + if (isDownloadPollingActive) return; + + console.log('Starting download status polling (1-second interval)'); + isDownloadPollingActive = true; + + // Initial call + updateDownloadQueues(); + + // Start 1-second polling (matching GUI's 1000ms timer) + downloadStatusInterval = setInterval(updateDownloadQueues, 1000); +} + +function stopDownloadPolling() { + if (downloadStatusInterval) { + clearInterval(downloadStatusInterval); + downloadStatusInterval = null; + } + isDownloadPollingActive = false; + console.log('Stopped download status polling'); +} + +async function updateDownloadQueues() { + try { + const response = await fetch('/api/downloads/status'); + const data = await response.json(); + + if (data.error) { + console.error("Error fetching download status:", data.error); + return; + } + + const newActive = {}; + const newFinished = {}; + + // Terminal states matching GUI logic + const terminalStates = ['Completed', 'Succeeded', 'Cancelled', 'Canceled', 'Failed', 'Errored']; + + // Process transfers exactly like GUI + data.transfers.forEach(item => { + const isTerminal = terminalStates.some(state => + item.state && item.state.includes(state) + ); + + if (isTerminal) { + newFinished[item.id] = item; + } else { + newActive[item.id] = item; + } + }); + + // Update global state + activeDownloads = newActive; + finishedDownloads = newFinished; + + // Render both queues + renderQueue('active-queue', activeDownloads, true); + renderQueue('finished-queue', finishedDownloads, false); + + // Update tab counts + updateTabCounts(); + + // Update stats in the side panel + updateDownloadStats(); + + } catch (error) { + // Only log errors occasionally to avoid console spam + if (Math.random() < 0.1) { + console.error("Failed to update download queues:", error); + } + } +} + +function renderQueue(containerId, downloads, isActiveQueue) { + const container = document.getElementById(containerId); + if (!container) return; + + const downloadIds = Object.keys(downloads); + + if (downloadIds.length === 0) { + container.innerHTML = `
${isActiveQueue ? 'No active downloads.' : 'No finished downloads.'}
`; + return; + } + + let html = ''; + for (const id of downloadIds) { + const item = downloads[id]; + const title = item.filename ? item.filename.split(/[\\/]/).pop() : 'Unknown File'; + const progress = item.percentComplete || 0; + const bytesTransferred = item.bytesTransferred || 0; + const totalBytes = item.size || 0; + const speed = item.averageSpeed || 0; + + // Format file size + const formatSize = (bytes) => { + if (!bytes) return 'Unknown size'; + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + return `${size.toFixed(1)} ${units[unitIndex]}`; + }; + + // Format speed + const formatSpeed = (bytesPerSecond) => { + if (!bytesPerSecond || bytesPerSecond <= 0) return ''; + return `${formatSize(bytesPerSecond)}/s`; + }; + + let actionButtonHTML = ''; + if (isActiveQueue) { + // Active items get progress bar and cancel button + actionButtonHTML = ` +
+
+
+
+
+ ${item.state} - ${progress.toFixed(1)}% + ${speed > 0 ? `• ${formatSpeed(speed)}` : ''} + ${totalBytes > 0 ? `• ${formatSize(bytesTransferred)} / ${formatSize(totalBytes)}` : ''} +
+
+ + `; + } else { + // Finished items get status and open button + let statusClass = ''; + if (item.state.includes('Cancelled')) statusClass = 'status--cancelled'; + else if (item.state.includes('Failed') || item.state.includes('Errored')) statusClass = 'status--failed'; + else if (item.state.includes('Completed') || item.state.includes('Succeeded')) statusClass = 'status--completed'; + + actionButtonHTML = ` +
+ ${item.state} +
+ + `; + } + + html += ` +
+
+
${title}
+
from ${item.username}
+
+
+ ${actionButtonHTML} +
+
+ `; + } + container.innerHTML = html; +} + +function updateTabCounts() { + const activeCount = Object.keys(activeDownloads).length; + const finishedCount = Object.keys(finishedDownloads).length; + + const activeTabBtn = document.querySelector('.tab-btn[data-tab="active-queue"]'); + const finishedTabBtn = document.querySelector('.tab-btn[data-tab="finished-queue"]'); + + if (activeTabBtn) activeTabBtn.textContent = `Download Queue (${activeCount})`; + if (finishedTabBtn) finishedTabBtn.textContent = `Finished (${finishedCount})`; +} + +function updateDownloadStats() { + const activeCount = Object.keys(activeDownloads).length; + const finishedCount = Object.keys(finishedDownloads).length; + + const activeLabel = document.getElementById('active-downloads-label'); + const finishedLabel = document.getElementById('finished-downloads-label'); + + if (activeLabel) activeLabel.textContent = `• Active Downloads: ${activeCount}`; + if (finishedLabel) finishedLabel.textContent = `• Finished Downloads: ${finishedCount}`; +} + +function initializeDownloadTabs() { + const tabButtons = document.querySelectorAll('.tab-btn'); + tabButtons.forEach(btn => { + btn.addEventListener('click', () => switchDownloadTab(btn)); + }); +} + +function switchDownloadTab(button) { + const targetTabId = button.getAttribute('data-tab'); + + // Update buttons + document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); + button.classList.add('active'); + + // Update content panes + document.querySelectorAll('.download-queue').forEach(queue => queue.classList.remove('active')); + const targetQueue = document.getElementById(targetTabId); + if (targetQueue) targetQueue.classList.add('active'); +} + +async function cancelDownloadItem(downloadId, username) { + if (!confirm('Are you sure you want to cancel this download?')) return; + + try { + const response = await fetch('/api/downloads/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ download_id: downloadId, username: username }) + }); + const result = await response.json(); + + if (result.success) { + showToast('Download cancelled', 'success'); + } else { + showToast(`Failed to cancel: ${result.error}`, 'error'); + } + } catch (error) { + console.error('Error cancelling download:', error); + showToast('Error sending cancel request', 'error'); + } +} + +async function clearFinishedDownloads() { + const finishedCount = Object.keys(finishedDownloads).length; + if (finishedCount === 0) { + showToast('No finished downloads to clear', 'error'); + return; + } + + if (!confirm(`Are you sure you want to clear all ${finishedCount} finished downloads?`)) return; + + try { + const response = await fetch('/api/downloads/clear-finished', { + method: 'POST' + }); + const result = await response.json(); + + if (result.success) { + showToast('Finished downloads cleared', 'success'); + } else { + showToast(`Failed to clear: ${result.error}`, 'error'); + } + } catch (error) { + console.error('Error clearing finished downloads:', error); + showToast('Error sending clear request', 'error'); + } } async function performDownloadsSearch() { @@ -1519,4 +1792,7 @@ window.startDownload = startDownload; window.downloadTrack = downloadTrack; window.downloadAlbum = downloadAlbum; window.toggleAlbumExpansion = toggleAlbumExpansion; -window.downloadAlbumTrack = downloadAlbumTrack; \ No newline at end of file +window.downloadAlbumTrack = downloadAlbumTrack; +window.switchDownloadTab = switchDownloadTab; +window.cancelDownloadItem = cancelDownloadItem; +window.clearFinishedDownloads = clearFinishedDownloads; \ No newline at end of file diff --git a/webui/static/style.css b/webui/static/style.css index 45645a50..b209361d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -2287,4 +2287,215 @@ body { rgba(30, 215, 96, 0.9), rgba(25, 180, 80, 0.8)); border-color: rgba(29, 185, 84, 0.6); +} + +/* Sophisticated Download Tracking Styles */ + +/* Download Queue Items */ +.download-item { + background: linear-gradient(to bottom, + rgba(45, 45, 50, 0.95), + rgba(35, 35, 40, 0.98)); + border: 1px solid rgba(65, 65, 70, 0.5); + border-radius: 12px; + margin: 6px 0; + padding: 12px; + transition: all 0.2s ease; +} + +.download-item:hover { + background: linear-gradient(to bottom, + rgba(50, 50, 55, 0.98), + rgba(40, 40, 45, 1.0)); + border-color: rgba(29, 185, 84, 0.6); +} + +.download-item__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.download-item__title { + font-size: 13px; + font-weight: 600; + color: #ffffff; + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.download-item__uploader { + font-size: 10px; + color: rgba(29, 185, 84, 1.0); + font-weight: 500; +} + +.download-item__content { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Progress Bar Styles */ +.download-item__progress-container { + display: flex; + flex-direction: column; + gap: 4px; +} + +.progress-bar { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #1ed760, #1db954); + border-radius: 3px; + transition: width 0.3s ease; +} + +.progress-text { + font-size: 10px; + color: #b3b3b3; + display: flex; + justify-content: space-between; + align-items: center; +} + +/* Action Buttons */ +.download-item__cancel-btn { + background: linear-gradient(to bottom, + rgba(255, 69, 58, 0.9), + rgba(255, 45, 85, 0.8)); + border: 1px solid rgba(255, 69, 58, 0.4); + border-radius: 6px; + color: #ffffff; + font-size: 11px; + font-weight: 600; + padding: 6px 12px; + cursor: pointer; + transition: all 0.2s ease; + align-self: flex-start; +} + +.download-item__cancel-btn:hover { + background: linear-gradient(to bottom, + rgba(255, 69, 58, 1.0), + rgba(255, 45, 85, 0.9)); + border-color: rgba(255, 69, 58, 0.6); +} + +.download-item__status-container { + display: flex; + align-items: center; + gap: 8px; +} + +.download-item__status-text { + font-size: 11px; + font-weight: 500; + padding: 4px 8px; + border-radius: 4px; +} + +.download-item__status-text.status--completed { + background: rgba(29, 185, 84, 0.2); + color: #1ed760; + border: 1px solid rgba(29, 185, 84, 0.4); +} + +.download-item__status-text.status--cancelled { + background: rgba(255, 159, 10, 0.2); + color: #ff9f0a; + border: 1px solid rgba(255, 159, 10, 0.4); +} + +.download-item__status-text.status--failed { + background: rgba(255, 69, 58, 0.2); + color: #ff453a; + border: 1px solid rgba(255, 69, 58, 0.4); +} + +.download-item__open-btn { + background: rgba(29, 185, 84, 0.1); + color: rgba(29, 185, 84, 0.6); + border: 1px solid rgba(29, 185, 84, 0.2); + border-radius: 6px; + font-size: 11px; + padding: 6px 12px; + cursor: not-allowed; + opacity: 0.6; +} + +/* Tab Management */ +.download-manager__tabs .tab-btn { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 10px 16px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: all 0.2s ease; + margin-right: 8px; +} + +.download-manager__tabs .tab-btn.active { + background: linear-gradient(to bottom, + rgba(29, 185, 84, 0.2), + rgba(29, 185, 84, 0.1)); + color: #1ed760; + border-color: rgba(29, 185, 84, 0.4); +} + +.download-manager__tabs .tab-btn:hover:not(.active) { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); +} + +/* Queue Content */ +.download-queue { + display: none; + max-height: 300px; + overflow-y: auto; + padding: 8px; +} + +.download-queue.active { + display: block; +} + +.download-queue__empty-message { + text-align: center; + color: rgba(255, 255, 255, 0.5); + font-size: 12px; + padding: 20px; + font-style: italic; +} + +/* Scrollbar Styling for Download Queue */ +.download-queue::-webkit-scrollbar { + width: 6px; +} + +.download-queue::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 3px; +} + +.download-queue::-webkit-scrollbar-thumb { + background: rgba(29, 185, 84, 0.3); + border-radius: 3px; +} + +.download-queue::-webkit-scrollbar-thumb:hover { + background: rgba(29, 185, 84, 0.5); } \ No newline at end of file