This commit is contained in:
Broque Thomas 2025-08-25 16:16:31 -07:00
parent b9879c4375
commit b84a3ae108
3 changed files with 263 additions and 46 deletions

View file

@ -874,6 +874,15 @@ def start_download():
return jsonify({"error": str(e)}), 500
def _find_completed_file_robust(download_dir, api_filename):
"""
Robustly finds a completed file on disk, accounting for name variations and
@ -929,6 +938,8 @@ def _find_completed_file_robust(download_dir, api_filename):
return None
@app.route('/api/downloads/status')
def get_download_status():
"""
@ -1021,6 +1032,7 @@ def get_download_status():
@app.route('/api/downloads/cancel', methods=['POST'])
def cancel_download():
"""
@ -1780,6 +1792,8 @@ def _search_track_in_album_context(original_search: dict, artist: dict) -> dict:
return None
def _detect_album_info_web(context: dict, artist: dict) -> dict:
"""
This is the final, corrected version that ensures the official Spotify track
@ -2022,6 +2036,8 @@ def _download_cover_art(album_info: dict, target_dir: str):
print(f"❌ Error downloading cover.jpg: {e}")
def _get_spotify_album_tracks(spotify_album: dict) -> list:
"""Fetches all tracks for a given Spotify album ID."""
if not spotify_album or not spotify_album.get('id'):
@ -2086,6 +2102,7 @@ def _match_track_to_spotify_title(slsk_track_meta: dict, spotify_tracks: list) -
return slsk_track_meta # Fallback to original
# --- Post-Processing Logic ---
def _post_process_matched_download(context_key, context, file_path):
"""
@ -2201,9 +2218,6 @@ def _post_process_matched_download(context_key, context, file_path):
matched_downloads_context[context_key] = context
print(f"♻️ Re-added {context_key} to context for retry")
# Keep track of processed downloads to avoid re-processing
_processed_download_ids = set()
@ -2771,31 +2785,90 @@ def start_playlist_missing_downloads(playlist_id):
@app.route('/api/playlists/<batch_id>/download_status', methods=['GET'])
def get_batch_download_status(batch_id):
"""
This endpoint returns real-time status for all tasks in a batch,
enabling live progress tracking in the modal.
Returns real-time status for all tasks in a batch. This version correctly
parses the live 'state' from slskd to report 'completed' status, fixing
the UI issue where downloads would get stuck at 100%.
"""
try:
# --- Fetch live transfer data from slskd ---
live_transfers_lookup = {}
try:
transfers_data = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads'))
if transfers_data:
all_transfers = []
for user_data in transfers_data:
username = user_data.get('username', 'Unknown')
if 'directories' in user_data:
for directory in user_data['directories']:
if 'files' in directory:
for file_info in directory['files']:
file_info['username'] = username
all_transfers.append(file_info)
# Create a lookup dictionary using a reliable composite key
for transfer in all_transfers:
key = f"{transfer.get('username')}::{os.path.basename(transfer.get('filename', ''))}"
live_transfers_lookup[key] = transfer
except Exception as e:
print(f"⚠️ Could not fetch live transfers for modal status: {e}")
# --- Process tasks and enrich with live data ---
with tasks_lock:
batch_tasks = []
for task_id, task in download_tasks.items():
if task.get('batch_id') == batch_id:
task_status = {
'task_id': task_id,
'track_index': task['track_index'],
'status': task['status'],
'track_info': task['track_info'],
'download_id': task.get('download_id'),
'username': task.get('username')
}
batch_tasks.append(task_status)
print(f"🔧 [Status API] Task {task_id} track_index {task['track_index']} status: {task['status']}")
# Sort by track_index to maintain order
if batch_id not in download_batches:
return jsonify({"tasks": []})
for task_id in download_batches[batch_id].get('queue', []):
task = download_tasks.get(task_id)
if not task:
continue
task_status = {
'task_id': task_id,
'track_index': task['track_index'],
'status': task['status'],
'track_info': task['track_info'],
'progress': 0
}
# --- USE THE RELIABLE KEY FOR MATCHING ---
task_filename = task.get('filename') or task['track_info'].get('filename')
task_username = task.get('username') or task['track_info'].get('username')
if task_filename and task_username:
lookup_key = f"{task_username}::{os.path.basename(task_filename)}"
if lookup_key in live_transfers_lookup:
live_info = live_transfers_lookup[lookup_key]
# --- THIS IS THE KEY FIX ---
# Correctly parse the live state string from the API
state_str = live_info.get('state', 'Unknown')
if 'Completed' in state_str or 'Succeeded' in state_str:
task_status['status'] = 'completed'
elif 'Cancelled' in state_str or 'Canceled' in state_str:
task_status['status'] = 'cancelled'
elif 'Failed' in state_str or 'Errored' in state_str:
task_status['status'] = 'failed'
elif 'InProgress' in state_str:
task_status['status'] = 'downloading'
else:
task_status['status'] = 'queued'
# --- END OF FIX ---
task_status['progress'] = live_info.get('percentComplete', 0)
print(f"🔧 [Status API] Live Update for Task {task_id}: Status '{task_status['status']}', Progress {task_status['progress']}%")
batch_tasks.append(task_status)
batch_tasks.sort(key=lambda x: x['track_index'])
return jsonify({"tasks": batch_tasks})
except Exception as e:
import traceback
traceback.print_exc()
print(f"❌ Error getting batch status: {e}")
return jsonify({"error": str(e)}), 500

View file

@ -2018,24 +2018,21 @@ function startModalDownloadPolling() {
let failedCount = 0;
let totalTasks = tasks.length;
// Update each track's status - but only for missing tracks
// Update each track's status in the modal
for (const task of tasks) {
const trackIndex = task.track_index;
const row = document.querySelector(`tr[data-track-index="${trackIndex}"]`);
// Only update if this is a missing track (has a matching entry in missingTracks array)
const isMissingTrack = missingTracks.some(mt => mt.track_index === trackIndex);
if (row && isMissingTrack) {
const statusElement = row.querySelector('.track-download-status');
const actionsElement = row.querySelector('.track-actions');
// Store task ID for cancellation
row.dataset.taskId = task.task_id;
const status = task.status;
const progress = task.progress || 0; // Get live progress from backend
switch (status) {
case 'pending':
statusElement.textContent = '⏸️ Pending';
@ -2048,15 +2045,9 @@ function startModalDownloadPolling() {
actionsElement.innerHTML = `<button class="cancel-track-btn" onclick="cancelTrackDownload(${trackIndex})">Cancel</button>`;
break;
case 'downloading':
statusElement.textContent = '⏬ Downloading...';
statusElement.textContent = `⏬ Downloading... ${Math.round(progress)}%`;
statusElement.className = 'track-download-status download-downloading';
actionsElement.innerHTML = `<button class="cancel-track-btn" onclick="cancelTrackDownload(${trackIndex})">Cancel</button>`;
// Start live download polling when we detect actual downloads have begun
if (!isDownloadPollingActive) {
console.log('🔄 Download detected - starting live download polling integration');
startDownloadPolling();
}
break;
case 'completed':
statusElement.textContent = '✅ Completed';
@ -2084,13 +2075,11 @@ function startModalDownloadPolling() {
}
}
// Update progress
// Update progress bar and stats
const progressPercent = totalTasks > 0 ? ((completedCount + failedCount) / totalTasks) * 100 : 0;
document.getElementById('download-progress-fill').style.width = `${progressPercent}%`;
document.getElementById('download-progress-text').textContent =
`${completedCount}/${totalTasks} completed (${progressPercent.toFixed(0)}%)`;
// Update downloaded count
document.getElementById('stat-downloaded').textContent = completedCount;
// Stop polling when all tasks are complete
@ -2099,26 +2088,16 @@ function startModalDownloadPolling() {
modalDownloadPoller = null;
document.getElementById('cancel-all-btn').style.display = 'none';
console.log('✅ All download tasks completed, stopping polling');
// Also stop live download polling if we started it
if (isDownloadPollingActive) {
stopDownloadPolling();
}
if (completedCount > 0) {
showToast(`Download completed: ${completedCount} tracks downloaded successfully!`, 'success');
}
}
// Update modal tracks with live download progress from the actual download queue
updateModalWithLiveDownloadProgress();
} catch (error) {
console.error('Error polling download status:', error);
}
}, 2000); // Poll every 2 seconds
}
async function updateModalWithLiveDownloadProgress() {
try {
if (!currentDownloadBatchId) return;
@ -3767,6 +3746,165 @@ function applyFiltersAndSort() {
displayDownloadsResults(processedResults);
}
function calculateRelevanceScore(result, query) {
let score = 0.0;
const queryTerms = query.split(' ').filter(t => t.length > 1);
// 1. Search Term Matching (40%)
let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase();
let termMatches = 0;
for (const term of queryTerms) {
if (searchableText.includes(term)) {
termMatches++;
}
}
score += (termMatches / queryTerms.length) * 0.40;
// 2. Quality Score (25%)
score += (result.quality_score || 0) * 0.25;
// 3. User Reliability (Availability & Speed) (20%)
const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5;
score += reliability * 0.20;
// 4. File Completeness (Bitrate & Duration) (15%)
const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0);
score += completeness * 0.15;
return score;
}
// APPEND THIS JAVASCRIPT SNIPPET (B)
function initializeFilters() {
const toggleBtn = document.getElementById('filter-toggle-btn');
const container = document.getElementById('filters-container');
const content = document.getElementById('filter-content');
if (toggleBtn && container && content) {
// Using .onclick ensures we only ever have one click handler
toggleBtn.onclick = () => {
const isExpanded = container.classList.contains('expanded');
if (isExpanded) {
// Collapse the container
container.classList.remove('expanded');
toggleBtn.textContent = '⏷ Filters';
} else {
// Expand the container
content.classList.remove('hidden'); // Make sure content is visible for animation
container.classList.add('expanded');
toggleBtn.textContent = '⏶ Filters';
}
};
}
// This part is correct and doesn't need to change
document.querySelectorAll('.filter-btn').forEach(button => {
button.addEventListener('click', handleFilterClick);
});
}
function handleFilterClick(event) {
const button = event.target;
const filterType = button.dataset.filterType;
const value = button.dataset.value;
if (filterType === 'type') currentFilterType = value;
if (filterType === 'format') currentFilterFormat = value;
if (filterType === 'sort') currentSortBy = value;
if (button.id === 'sort-order-btn') {
isSortReversed = !isSortReversed;
button.textContent = isSortReversed ? '↑' : '↓';
}
document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => {
btn.classList.remove('active');
});
if (filterType) { // Don't try to activate the sort order button
button.classList.add('active');
}
applyFiltersAndSort();
}
function resetFilters() {
currentFilterType = 'all';
currentFilterFormat = 'all';
currentSortBy = 'quality_score';
isSortReversed = false;
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active');
document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active');
document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active');
document.getElementById('sort-order-btn').textContent = '↓';
}
function applyFiltersAndSort() {
let processedResults = [...allSearchResults];
const query = document.getElementById('downloads-search-input').value.trim().toLowerCase();
// 1. Filter by Type
if (currentFilterType !== 'all') {
processedResults = processedResults.filter(r => r.result_type === currentFilterType);
}
// 2. Filter by Format
if (currentFilterFormat !== 'all') {
processedResults = processedResults.filter(r => {
const quality = (r.dominant_quality || r.quality || '').toLowerCase();
return quality === currentFilterFormat;
});
}
// 3. Sort Results
processedResults.sort((a, b) => {
let valA, valB;
// Special handling for relevance sort
if (currentSortBy === 'relevance') {
valA = calculateRelevanceScore(a, query);
valB = calculateRelevanceScore(b, query);
return valB - valA; // Higher score is better
}
// Special handling for availability
if (currentSortBy === 'availability') {
valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1;
valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1;
return valB - valA;
}
valA = a[currentSortBy] || 0;
valB = b[currentSortBy] || 0;
if (typeof valA === 'string') {
// For name/title sort, use the correct property
const titleA = (a.album_title || a.title || '').toLowerCase();
const titleB = (b.album_title || b.title || '').toLowerCase();
return titleA.localeCompare(titleB);
}
// Default numeric sort (descending)
return valB - valA;
});
// Handle sort direction toggle
const sortDefaults = {
relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc',
upload_speed: 'desc', duration: 'desc', availability: 'desc',
title: 'asc', username: 'asc'
};
const defaultOrder = sortDefaults[currentSortBy] || 'desc';
if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) {
processedResults.reverse();
}
displayDownloadsResults(processedResults);
}
function calculateRelevanceScore(result, query) {
let score = 0.0;
const queryTerms = query.split(' ').filter(t => t.length > 1);
@ -4331,6 +4469,8 @@ async function confirmMatch() {
}
function matchedDownloadTrack(trackIndex) {
const results = window.currentSearchResults;
if (!results || !results[trackIndex]) {
@ -4612,4 +4752,4 @@ async function handleDbUpdateButtonClick() {
showToast('Error sending stop request.', 'error');
}
}
}
}

View file

@ -4827,6 +4827,10 @@ body {
width: 80px;
}
.track-result-card .track-actions{
width: auto;
}
.cancel-track-btn {
background: #f44336;
color: #ffffff;