Remove embedded Download Manager from Search page, bump to 2.44

Phase 3c of the Search/Artists unification. The Search page carried
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 now gone.

Removed:
  - Side-panel HTML block and the toggle button that showed/hid it
  - ~290 lines of polling + render infra in downloads.js: loadDownloads-
    Data, startDownloadPolling/stopDownloadPolling, updateDownload-
    Queues, renderQueue, updateTabCounts/updateDownloadStats,
    initializeDownloadTabs/switchDownloadTab, cancelDownloadItem,
    clearFinishedDownloads, cancelAllDownloads, and the
    activeDownloads/finishedDownloads globals
  - initializeDownloadManagerToggle and its call from init.js
  - Stopped hitting /api/downloads/status every second on the Search
    page (the dedicated Downloads page already polls its own view)

CSS grid for the Search page collapsed from '1fr 370px' to '1fr' now
that the right panel is gone. Unused .controls-panel__* / .download-
manager__* / .downloads-side-panel CSS rules kept in place — harmless,
can be pruned later.
pull/361/head
Broque Thomas 1 month ago
parent 6992e2e5b5
commit f203b3e46d

@ -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",

@ -1848,10 +1848,10 @@
This top-level container replicates the QSplitter from downloads.py,
creating the two-panel layout for the page.
-->
<div class="downloads-content manager-hidden">
<div class="downloads-content">
<!-- ======================================================= -->
<!-- == LEFT PANEL: Search, Filters, and Results == -->
<!-- == Search page main panel == -->
<!-- ======================================================= -->
<div class="downloads-main-panel">
@ -1859,13 +1859,9 @@
<div class="downloads-header">
<div class="downloads-header-content">
<div class="downloads-header-text">
<h2 class="downloads-title"><img src="/static/search.png" class="page-header-icon" alt=""><span>Music Downloads</span></h2>
<p class="downloads-subtitle">Search, discover, and download high-quality music</p>
<h2 class="downloads-title"><img src="/static/search.png" class="page-header-icon" alt=""><span>Search</span></h2>
<p class="downloads-subtitle">Find artists, albums, and tracks from any metadata source</p>
</div>
<button id="toggle-download-manager-btn" class="toggle-manager-btn"
title="Toggle Download Manager">
<span class="toggle-icon"></span>
</button>
</div>
</div>
@ -2107,44 +2103,6 @@
<!-- End Enhanced Search Section -->
</div>
<!-- ======================================================= -->
<!-- == RIGHT PANEL: Controls and Download Queue == -->
<!-- ======================================================= -->
<div class="downloads-side-panel">
<!-- Controls Panel: Replicates create_collapsible_controls_panel() -->
<div class="controls-panel">
<h3 class="controls-panel__header">Download Manager</h3>
<div class="controls-panel__stats">
<p id="active-downloads-label">• Active Downloads: 0</p>
<p id="finished-downloads-label">• Finished Downloads: 0</p>
</div>
<div class="controls-panel__actions">
<button class="controls-panel__clear-btn">🗑️ Clear Completed</button>
<button class="controls-panel__cancel-all-btn">⛔ Clear Current</button>
</div>
</div>
<!-- Download Queue: Replicates TabbedDownloadManager -->
<div class="download-manager">
<div class="download-manager__tabs">
<button class="tab-btn active" data-tab="active-queue">Download Queue (0)</button>
<button class="tab-btn" data-tab="finished-queue">Finished (0)</button>
</div>
<div class="download-manager__content">
<!-- Active Queue -->
<div class="download-queue active" id="active-queue">
<div class="download-queue__empty-message">No active downloads.</div>
</div>
<!-- Finished Queue -->
<div class="download-queue" id="finished-queue">
<div class="download-queue__empty-message">No finished downloads.</div>
</div>
</div>
</div>
</div>
</div>
</div>

@ -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 = `<div class="download-queue__empty-message">${isActiveQueue ? 'No active downloads.' : 'No finished downloads.'}</div>`;
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 = `
<div class="download-item__progress-container">
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%;"></div>
</div>
<div class="progress-text">
${item.state} - ${progress.toFixed(1)}%
${speed > 0 ? `${formatSpeed(speed)}` : ''}
${totalBytes > 0 ? `${formatSize(bytesTransferred)} / ${formatSize(totalBytes)}` : ''}
</div>
</div>
<button class="download-item__cancel-btn" onclick="cancelDownloadItem('${item.id}', '${item.username}')"> Cancel</button>
`;
} 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 = `
<div class="download-item__status-container">
<span class="download-item__status-text ${statusClass}">${item.state}</span>
</div>
<button class="download-item__open-btn" title="Cannot open folder from web browser" disabled>📁 Open</button>
`;
}
// 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 += `
<div class="download-item" data-id="${item.id}">
<div class="download-item__art">
${meta.artwork_url
? `<img src="${meta.artwork_url}" alt="" loading="lazy" onerror="this.parentElement.innerHTML='<div class=\\'download-item__art-placeholder\\'>&#9835;</div>'">`
: '<div class="download-item__art-placeholder">&#9835;</div>'}
</div>
<div class="download-item__info">
<div class="download-item__title" title="${title}">${title}</div>
${meta.artist || meta.album ? `
<div class="download-item__meta">
${meta.artist ? `<span>${escapeHtml(meta.artist)}</span>` : ''}
${meta.artist && meta.album ? '<span class="download-item__sep">&middot;</span>' : ''}
${meta.album ? `<span class="download-item__album">${escapeHtml(meta.album)}</span>` : ''}
</div>
` : ''}
<div class="download-item__badges">
<span class="download-item__source">${sourceBadge}</span>
${meta.quality ? `<span class="download-item__quality">${escapeHtml(meta.quality)}</span>` : ''}
</div>
</div>
<div class="download-item__content">
${actionButtonHTML}
</div>
</div>
`;
}
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) {

@ -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)' },

@ -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

@ -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 */

Loading…
Cancel
Save