This commit is contained in:
Broque Thomas 2025-08-23 10:39:12 -07:00
parent a78732fb08
commit afd5662afa
3 changed files with 563 additions and 1 deletions

View file

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

View file

@ -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 = `<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];
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 = `
<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>
`;
}
html += `
<div class="download-item" data-id="${item.id}">
<div class="download-item__header">
<div class="download-item__title" title="${title}">${title}</div>
<div class="download-item__uploader" title="from ${item.username}">from ${item.username}</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) {
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;
window.downloadAlbumTrack = downloadAlbumTrack;
window.switchDownloadTab = switchDownloadTab;
window.cancelDownloadItem = cancelDownloadItem;
window.clearFinishedDownloads = clearFinishedDownloads;

View file

@ -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);
}