diff --git a/web_server.py b/web_server.py index 3c1d1b35..7b01e306 100644 --- a/web_server.py +++ b/web_server.py @@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, version-info endpoint, etc. -_SOULSYNC_BASE_VERSION = "2.43" +_SOULSYNC_BASE_VERSION = "2.44" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -22809,6 +22809,16 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "Remove Embedded Download Manager from Search Page", + "description": "The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone", + "features": [ + "• Toggle button, side-panel HTML, and its 1-second polling loop removed — Downloads page is now the single downloads UI", + "• About 330 lines of dead code gone across downloads.js and init.js", + "• CSS grid for the Search page collapsed to a single-column layout now that the right panel is gone", + "• Phase 3c of the Search/Artists unification project", + ], + }, { "title": "Search Page Renamed to /search", "description": "The Search page's internal id is now 'search' instead of 'downloads', which no longer conflicts with the real Downloads page. URL /downloads still works for bookmarks and external links", diff --git a/webui/index.html b/webui/index.html index 8e196335..053245a9 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1848,10 +1848,10 @@ This top-level container replicates the QSplitter from downloads.py, creating the two-panel layout for the page. --> -
+
- +
@@ -1859,13 +1859,9 @@
-

Music Downloads

-

Search, discover, and download high-quality music

+

Search

+

Find artists, albums, and tracks from any metadata source

-
@@ -2107,44 +2103,6 @@
- - - -
- - -
-

Download Manager

-
-

• Active Downloads: 0

-

• Finished Downloads: 0

-
-
- - -
-
- - -
-
- - -
-
- - -
-
No active downloads.
-
- - -
-
No finished downloads.
-
-
-
-
diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 9fdd9bef..7348f4ca 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -4267,335 +4267,7 @@ function updateModalSyncProgress(playlistId, progress) { } -// 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'); - - // Event listeners are already set up in initializeSearch() - don't duplicate them - const clearButton = document.querySelector('.controls-panel__clear-btn'); - const cancelAllButton = document.querySelector('.controls-panel__cancel-all-btn'); - - if (clearButton) { - clearButton.addEventListener('click', clearFinishedDownloads); - } - if (cancelAllButton) { - cancelAllButton.addEventListener('click', cancelAllDownloads); - } - - // 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() { - if (document.hidden) return; // Skip polling when tab is not visible - 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]; - - // Extract display title from filename - let title = 'Unknown File'; - if (item.filename) { - // YouTube/Tidal filenames are encoded as "id||title" - if ((item.username === 'youtube' || item.username === 'tidal' || item.username === 'qobuz' || item.username === 'hifi') && item.filename.includes('||')) { - const parts = item.filename.split('||'); - title = parts[1] || parts[0]; // Use title part, fallback to id - } else { - // Regular Soulseek filename - extract last part of path - title = item.filename.split(/[\\/]/).pop(); - } - } - - 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} -
- - `; - } - - // Enrich with metadata from backend context (artist, album, artwork) - const meta = item._meta || {}; - const sourceLabels = { youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', lidarr: 'Lidarr' }; - const sourceBadge = sourceLabels[item.username] || item.username; - - html += ` -
-
- ${meta.artwork_url - ? `` - : '
'} -
-
-
${title}
- ${meta.artist || meta.album ? ` -
- ${meta.artist ? `${escapeHtml(meta.artist)}` : ''} - ${meta.artist && meta.album ? '·' : ''} - ${meta.album ? `${escapeHtml(meta.album)}` : ''} -
- ` : ''} -
- ${sourceBadge} - ${meta.quality ? `${escapeHtml(meta.quality)}` : ''} -
-
-
- ${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) { - 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; - } - - 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 cancelAllDownloads() { - if (!await showConfirmDialog({ title: 'Cancel All Downloads', message: 'Cancel ALL active downloads and clear the transfer list? This cannot be undone.', confirmText: 'Cancel All', destructive: true })) { - return; - } - - try { - const response = await fetch('/api/downloads/cancel-all', { - method: 'POST' - }); - const result = await response.json(); - - if (result.success) { - showToast('All downloads cancelled and cleared', 'success'); - } else { - showToast(`Failed to cancel: ${result.error}`, 'error'); - } - } catch (error) { - console.error('Error cancelling all downloads:', error); - showToast('Error cancelling downloads', 'error'); - } -} - -// REPLACE the old performDownloadsSearch function with this new one. +// Raw Soulseek file search (used by the 'Soulseek (raw files)' source picker option). async function performDownloadsSearch() { const query = document.getElementById('downloads-search-input').value.trim(); if (!query) { diff --git a/webui/static/helper.js b/webui/static/helper.js index 2c4fd0e5..fe201416 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3599,6 +3599,11 @@ function closeHelperSearch() { // ═══════════════════════════════════════════════════════════════════════════ const WHATS_NEW = { + '2.44': [ + // --- April 23, 2026 (evening) --- + { date: 'April 23, 2026 (evening)' }, + { title: 'Remove Embedded Download Manager from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up across downloads.js and init.js. CSS grid for the Search page collapsed to single-column now that the right panel is gone. The dedicated Downloads sidebar page is now the single downloads UI. Phase 3c of the Search/Artists unification project', page: 'search' }, + ], '2.43': [ // --- April 23, 2026 (later) --- { date: 'April 23, 2026 (later)' }, diff --git a/webui/static/init.js b/webui/static/init.js index 8f1870f6..e8e38463 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -1925,7 +1925,6 @@ function initApp() { initExpandedPlayer(); initializeSyncPage(); initializeWatchlist(); - initializeDownloadManagerToggle(); // Initialize WebSocket connection (falls back to HTTP polling if unavailable) @@ -2115,37 +2114,6 @@ function initializeWatchlist() { console.log('Watchlist system initialized'); } -function initializeDownloadManagerToggle() { - const toggleButton = document.getElementById('toggle-download-manager-btn'); - const downloadsContent = document.querySelector('.downloads-content'); - - if (!toggleButton || !downloadsContent) { - console.log('Download manager toggle not found on this page'); - return; - } - - // Load saved state from localStorage (hidden by default for more search space) - const isHidden = localStorage.getItem('downloadManagerHidden') !== 'false'; - if (isHidden) { - downloadsContent.classList.add('manager-hidden'); - } - - // Add click handler - toggleButton.addEventListener('click', () => { - const isCurrentlyHidden = downloadsContent.classList.contains('manager-hidden'); - - if (isCurrentlyHidden) { - downloadsContent.classList.remove('manager-hidden'); - localStorage.setItem('downloadManagerHidden', 'false'); - } else { - downloadsContent.classList.add('manager-hidden'); - localStorage.setItem('downloadManagerHidden', 'true'); - } - }); - - console.log('Download manager toggle initialized'); -} - function navigateToPage(pageId, options = {}) { // Backwards-compat alias — the Search page used to live under id 'downloads'. if (pageId === 'downloads') pageId = 'search'; @@ -2249,7 +2217,6 @@ async function loadPageData(pageId) { initializeSearch(); initializeSearchModeToggle(); initializeFilters(); - await loadDownloadsData(); break; case 'artists': // Only fully initialize if not already initialized diff --git a/webui/static/style.css b/webui/static/style.css index 5cc7d083..7298bc44 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4255,8 +4255,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { /* Main Layout: Replicates QSplitter */ .downloads-content { display: grid; - grid-template-columns: 1fr 370px; - /* Left panel is flexible, right is fixed */ + grid-template-columns: 1fr; gap: 24px; height: 100%; /* Fill parent .page content area */