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 return jsonify({"error": str(e)}), 500
def _find_completed_file_robust(download_dir, api_filename): def _find_completed_file_robust(download_dir, api_filename):
""" """
Robustly finds a completed file on disk, accounting for name variations and 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 return None
@app.route('/api/downloads/status') @app.route('/api/downloads/status')
def get_download_status(): def get_download_status():
""" """
@ -1021,6 +1032,7 @@ def get_download_status():
@app.route('/api/downloads/cancel', methods=['POST']) @app.route('/api/downloads/cancel', methods=['POST'])
def cancel_download(): def cancel_download():
""" """
@ -1780,6 +1792,8 @@ def _search_track_in_album_context(original_search: dict, artist: dict) -> dict:
return None return None
def _detect_album_info_web(context: dict, artist: dict) -> dict: def _detect_album_info_web(context: dict, artist: dict) -> dict:
""" """
This is the final, corrected version that ensures the official Spotify track 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}") print(f"❌ Error downloading cover.jpg: {e}")
def _get_spotify_album_tracks(spotify_album: dict) -> list: def _get_spotify_album_tracks(spotify_album: dict) -> list:
"""Fetches all tracks for a given Spotify album ID.""" """Fetches all tracks for a given Spotify album ID."""
if not spotify_album or not spotify_album.get('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 return slsk_track_meta # Fallback to original
# --- Post-Processing Logic --- # --- Post-Processing Logic ---
def _post_process_matched_download(context_key, context, file_path): 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 matched_downloads_context[context_key] = context
print(f"♻️ Re-added {context_key} to context for retry") print(f"♻️ Re-added {context_key} to context for retry")
# Keep track of processed downloads to avoid re-processing # Keep track of processed downloads to avoid re-processing
_processed_download_ids = set() _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']) @app.route('/api/playlists/<batch_id>/download_status', methods=['GET'])
def get_batch_download_status(batch_id): def get_batch_download_status(batch_id):
""" """
This endpoint returns real-time status for all tasks in a batch, Returns real-time status for all tasks in a batch. This version correctly
enabling live progress tracking in the modal. parses the live 'state' from slskd to report 'completed' status, fixing
the UI issue where downloads would get stuck at 100%.
""" """
try: 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: with tasks_lock:
batch_tasks = [] batch_tasks = []
for task_id, task in download_tasks.items(): if batch_id not in download_batches:
if task.get('batch_id') == batch_id: return jsonify({"tasks": []})
task_status = {
'task_id': task_id, for task_id in download_batches[batch_id].get('queue', []):
'track_index': task['track_index'], task = download_tasks.get(task_id)
'status': task['status'], if not task:
'track_info': task['track_info'], continue
'download_id': task.get('download_id'),
'username': task.get('username') task_status = {
} 'task_id': task_id,
batch_tasks.append(task_status) 'track_index': task['track_index'],
print(f"🔧 [Status API] Task {task_id} track_index {task['track_index']} status: {task['status']}") 'status': task['status'],
'track_info': task['track_info'],
# Sort by track_index to maintain order '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']) batch_tasks.sort(key=lambda x: x['track_index'])
return jsonify({"tasks": batch_tasks}) return jsonify({"tasks": batch_tasks})
except Exception as e: except Exception as e:
import traceback
traceback.print_exc()
print(f"❌ Error getting batch status: {e}") print(f"❌ Error getting batch status: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500

View file

@ -2018,24 +2018,21 @@ function startModalDownloadPolling() {
let failedCount = 0; let failedCount = 0;
let totalTasks = tasks.length; 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) { for (const task of tasks) {
const trackIndex = task.track_index; const trackIndex = task.track_index;
const row = document.querySelector(`tr[data-track-index="${trackIndex}"]`); 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); const isMissingTrack = missingTracks.some(mt => mt.track_index === trackIndex);
if (row && isMissingTrack) { if (row && isMissingTrack) {
const statusElement = row.querySelector('.track-download-status'); const statusElement = row.querySelector('.track-download-status');
const actionsElement = row.querySelector('.track-actions'); const actionsElement = row.querySelector('.track-actions');
// Store task ID for cancellation
row.dataset.taskId = task.task_id; row.dataset.taskId = task.task_id;
const status = task.status; const status = task.status;
const progress = task.progress || 0; // Get live progress from backend
switch (status) { switch (status) {
case 'pending': case 'pending':
statusElement.textContent = '⏸️ Pending'; statusElement.textContent = '⏸️ Pending';
@ -2048,15 +2045,9 @@ function startModalDownloadPolling() {
actionsElement.innerHTML = `<button class="cancel-track-btn" onclick="cancelTrackDownload(${trackIndex})">Cancel</button>`; actionsElement.innerHTML = `<button class="cancel-track-btn" onclick="cancelTrackDownload(${trackIndex})">Cancel</button>`;
break; break;
case 'downloading': case 'downloading':
statusElement.textContent = '⏬ Downloading...'; statusElement.textContent = `⏬ Downloading... ${Math.round(progress)}%`;
statusElement.className = 'track-download-status download-downloading'; statusElement.className = 'track-download-status download-downloading';
actionsElement.innerHTML = `<button class="cancel-track-btn" onclick="cancelTrackDownload(${trackIndex})">Cancel</button>`; 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; break;
case 'completed': case 'completed':
statusElement.textContent = '✅ 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; const progressPercent = totalTasks > 0 ? ((completedCount + failedCount) / totalTasks) * 100 : 0;
document.getElementById('download-progress-fill').style.width = `${progressPercent}%`; document.getElementById('download-progress-fill').style.width = `${progressPercent}%`;
document.getElementById('download-progress-text').textContent = document.getElementById('download-progress-text').textContent =
`${completedCount}/${totalTasks} completed (${progressPercent.toFixed(0)}%)`; `${completedCount}/${totalTasks} completed (${progressPercent.toFixed(0)}%)`;
// Update downloaded count
document.getElementById('stat-downloaded').textContent = completedCount; document.getElementById('stat-downloaded').textContent = completedCount;
// Stop polling when all tasks are complete // Stop polling when all tasks are complete
@ -2099,26 +2088,16 @@ function startModalDownloadPolling() {
modalDownloadPoller = null; modalDownloadPoller = null;
document.getElementById('cancel-all-btn').style.display = 'none'; document.getElementById('cancel-all-btn').style.display = 'none';
console.log('✅ All download tasks completed, stopping polling'); console.log('✅ All download tasks completed, stopping polling');
// Also stop live download polling if we started it
if (isDownloadPollingActive) {
stopDownloadPolling();
}
if (completedCount > 0) { if (completedCount > 0) {
showToast(`Download completed: ${completedCount} tracks downloaded successfully!`, 'success'); showToast(`Download completed: ${completedCount} tracks downloaded successfully!`, 'success');
} }
} }
// Update modal tracks with live download progress from the actual download queue
updateModalWithLiveDownloadProgress();
} catch (error) { } catch (error) {
console.error('Error polling download status:', error); console.error('Error polling download status:', error);
} }
}, 2000); // Poll every 2 seconds }, 2000); // Poll every 2 seconds
} }
async function updateModalWithLiveDownloadProgress() { async function updateModalWithLiveDownloadProgress() {
try { try {
if (!currentDownloadBatchId) return; if (!currentDownloadBatchId) return;
@ -3767,6 +3746,165 @@ function applyFiltersAndSort() {
displayDownloadsResults(processedResults); 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) { function calculateRelevanceScore(result, query) {
let score = 0.0; let score = 0.0;
const queryTerms = query.split(' ').filter(t => t.length > 1); const queryTerms = query.split(' ').filter(t => t.length > 1);
@ -4331,6 +4469,8 @@ async function confirmMatch() {
} }
function matchedDownloadTrack(trackIndex) { function matchedDownloadTrack(trackIndex) {
const results = window.currentSearchResults; const results = window.currentSearchResults;
if (!results || !results[trackIndex]) { if (!results || !results[trackIndex]) {
@ -4612,4 +4752,4 @@ async function handleDbUpdateButtonClick() {
showToast('Error sending stop request.', 'error'); showToast('Error sending stop request.', 'error');
} }
} }
} }

View file

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