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 @@
@@ -2107,44 +2103,6 @@
-
-
-
-
-
-
-
-
-
-
• Active Downloads: 0
-
• Finished Downloads: 0
-
-
- 🗑️ Clear Completed
- ⛔ Clear Current
-
-
-
-
-
-
- Download Queue (0)
- Finished (0)
-
-
-
-
-
-
-
-
-
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)}` : ''}
-
-
- ✕ Cancel
- `;
- } 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}
-
- 📁 Open
- `;
- }
-
- // 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 */