diff --git a/web_server.py b/web_server.py index 876e16f6..3a6cec9f 100644 --- a/web_server.py +++ b/web_server.py @@ -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//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 diff --git a/webui/static/script.js b/webui/static/script.js index c4806898..f84c3ee3 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 = ``; break; case 'downloading': - statusElement.textContent = '⏬ Downloading...'; + statusElement.textContent = `⏬ Downloading... ${Math.round(progress)}%`; statusElement.className = 'track-download-status download-downloading'; actionsElement.innerHTML = ``; - - // 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'); } } -} +} \ No newline at end of file diff --git a/webui/static/style.css b/webui/static/style.css index 828649c9..2312f9c9 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4827,6 +4827,10 @@ body { width: 80px; } +.track-result-card .track-actions{ + width: auto; +} + .cancel-track-btn { background: #f44336; color: #ffffff;