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 += `
+
+
+
+ ${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