diff --git a/web_server.py b/web_server.py index c67b2b7e..876e16f6 100644 --- a/web_server.py +++ b/web_server.py @@ -10,6 +10,8 @@ import threading import time import shutil import glob +import uuid +import re from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed @@ -111,6 +113,13 @@ db_update_lock = threading.Lock() matched_downloads_context = {} matched_context_lock = threading.Lock() +# --- Download Missing Tracks Modal State Management --- +# Thread-safe state tracking for modal download functionality with batch management +missing_download_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="MissingTrackWorker") +download_tasks = {} # task_id -> task state dict +download_batches = {} # batch_id -> {queue, active_count, max_concurrent} +tasks_lock = threading.Lock() + def _prepare_stream_task(track_data): """ Background streaming task that downloads track to Stream folder and updates global state. @@ -2387,6 +2396,442 @@ def stop_database_update(): else: return jsonify({"success": False, "error": "No update is currently running."}), 404 +# =============================== +# == DOWNLOAD MISSING TRACKS == +# =============================== + +def get_valid_candidates(results, spotify_track, query): + """ + This function is a direct port from sync.py. It scores and filters + Soulseek search results against a Spotify track to find the best, most + accurate download candidates. + """ + if not results: + return [] + # Uses the existing, powerful matching engine for scoring + initial_candidates = matching_engine.find_best_slskd_matches_enhanced(spotify_track, results) + if not initial_candidates: + return [] + + verified_candidates = [] + spotify_artist_name = spotify_track.artists[0] if spotify_track.artists else "" + normalized_spotify_artist = re.sub(r'[^a-zA-Z0-9]', '', spotify_artist_name).lower() + + for candidate in initial_candidates: + # This check is critical: it ensures the artist's name is in the file path, + # preventing downloads from the wrong artist. + normalized_slskd_path = re.sub(r'[^a-zA-Z0-9]', '', candidate.filename).lower() + if normalized_spotify_artist in normalized_slskd_path: + verified_candidates.append(candidate) + return verified_candidates + +def _start_next_batch_of_downloads(batch_id): + """Start the next batch of downloads up to the concurrent limit (like GUI)""" + with tasks_lock: + if batch_id not in download_batches: + return + + batch = download_batches[batch_id] + max_concurrent = batch['max_concurrent'] + queue = batch['queue'] + queue_index = batch['queue_index'] + active_count = batch['active_count'] + + # Start downloads up to the concurrent limit + while active_count < max_concurrent and queue_index < len(queue): + task_id = queue[queue_index] + + # IMPORTANT: Set status to 'searching' BEFORE starting worker (like GUI) + # Must be done INSIDE the lock to prevent race conditions with status polling + if task_id in download_tasks: + download_tasks[task_id]['status'] = 'searching' + print(f"πŸ”§ [Batch Manager] Set task {task_id} status to 'searching'") + + # Update counters + download_batches[batch_id]['active_count'] += 1 + download_batches[batch_id]['queue_index'] += 1 + + print(f"πŸ”„ [Batch Manager] Starting download {queue_index + 1}/{len(queue)} - Active: {active_count + 1}/{max_concurrent}") + + # Submit to executor + missing_download_executor.submit(_download_track_worker, task_id, batch_id) + + # Update local counters for next iteration + active_count += 1 + queue_index += 1 + +def _on_download_completed(batch_id, task_id, success=True): + """Called when a download completes to start the next one in queue""" + with tasks_lock: + if batch_id not in download_batches: + return + + # Decrement active count + download_batches[batch_id]['active_count'] -= 1 + + print(f"πŸ”„ [Batch Manager] Download completed. Active: {download_batches[batch_id]['active_count']}/{download_batches[batch_id]['max_concurrent']}") + + # Start next downloads in queue + _start_next_batch_of_downloads(batch_id) + +def _download_track_worker(task_id, batch_id=None): + """ + Enhanced download worker that matches the GUI's exact retry logic. + Implements sequential query retry, fallback candidates, and download failure retry. + """ + try: + # Retrieve task details from global state + with tasks_lock: + if task_id not in download_tasks: + print(f"❌ [Modal Worker] Task {task_id} not found in download_tasks") + return + task = download_tasks[task_id].copy() + + # Cancellation Checkpoint 1: Before doing anything + with tasks_lock: + if download_tasks[task_id]['status'] == 'cancelled': + print(f"❌ [Modal Worker] Task {task_id} cancelled before starting") + return + + track_data = task['track_info'] + + # Recreate a SpotifyTrack object for the matching engine + track = SpotifyTrack( + id=track_data.get('id', ''), + name=track_data.get('name', ''), + artists=track_data.get('artists', []), + album=track_data.get('album', ''), + duration_ms=track_data.get('duration_ms', 0), + popularity=track_data.get('popularity', 0) + ) + print(f"πŸ“₯ [Modal Worker] Starting download task for: {track.name} by {track.artists[0] if track.artists else 'Unknown'}") + + # Initialize task state tracking (like GUI's parallel_search_tracking) + with tasks_lock: + if task_id in download_tasks: + download_tasks[task_id]['status'] = 'searching' # Now actively being processed + download_tasks[task_id]['current_query_index'] = 0 + download_tasks[task_id]['current_candidate_index'] = 0 + download_tasks[task_id]['retry_count'] = 0 + download_tasks[task_id]['candidates'] = [] + download_tasks[task_id]['used_sources'] = set() + + # 1. Generate multiple search queries (like GUI's generate_smart_search_queries) + artist_name = track.artists[0] if track.artists else None + track_name = track.name + + # Start with matching engine queries + search_queries = matching_engine.generate_download_queries(track) + + # Add legacy fallback queries (like GUI does) + legacy_queries = [] + + if artist_name: + # Add first word of artist approach (legacy compatibility) + artist_words = artist_name.split() + if artist_words: + first_word = artist_words[0] + if first_word.lower() == 'the' and len(artist_words) > 1: + first_word = artist_words[1] + + if len(first_word) > 1: + legacy_queries.append(f"{track_name} {first_word}".strip()) + + # Add track-only query + if track_name.strip(): + legacy_queries.append(track_name.strip()) + + # Add traditional cleaned queries + cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip() + cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip() + + if cleaned_name and cleaned_name.lower() != track_name.lower(): + legacy_queries.append(cleaned_name.strip()) + + # Combine enhanced queries with legacy fallbacks + all_queries = search_queries + legacy_queries + + # Remove duplicates while preserving order + unique_queries = [] + seen = set() + for query in all_queries: + if query and query.lower() not in seen: + unique_queries.append(query) + seen.add(query.lower()) + + search_queries = unique_queries + print(f"πŸ” [Modal Worker] Generated {len(search_queries)} smart search queries for '{track.name}': {search_queries}") + + # 2. Sequential Query Search (matches GUI's start_search_worker_parallel logic) + for query_index, query in enumerate(search_queries): + # Cancellation check before each query + with tasks_lock: + if download_tasks[task_id]['status'] == 'cancelled': + print(f"❌ [Modal Worker] Task {task_id} cancelled during query {query_index + 1}") + return + download_tasks[task_id]['current_query_index'] = query_index + + print(f"πŸ” [Modal Worker] Query {query_index + 1}/{len(search_queries)}: '{query}'") + + try: + # Perform search with timeout + tracks_result, _ = asyncio.run(soulseek_client.search(query, timeout=30)) + if tracks_result: + # Validate candidates using GUI's get_valid_candidates logic + candidates = get_valid_candidates(tracks_result, track, query) + if candidates: + print(f"βœ… [Modal Worker] Found {len(candidates)} valid candidates for query '{query}'") + + # Store candidates and attempt download (like GUI) + with tasks_lock: + if task_id in download_tasks: + download_tasks[task_id]['candidates'] = candidates + + # Try to download with these candidates + success = _attempt_download_with_candidates(task_id, candidates, track) + if success: + # Notify batch manager that this task completed (success) + if batch_id: + _on_download_completed(batch_id, task_id, success=True) + return # Success, exit the worker + + except Exception as e: + print(f"⚠️ [Modal Worker] Search failed for query '{query}': {e}") + continue + + # If we get here, all search queries failed + print(f"❌ [Modal Worker] No valid candidates found for '{track.name}' after trying all {len(search_queries)} queries.") + with tasks_lock: + if task_id in download_tasks: + download_tasks[task_id]['status'] = 'failed' + + # Notify batch manager that this task completed (failed) + if batch_id: + _on_download_completed(batch_id, task_id, success=False) + + except Exception as e: + import traceback + print(f"❌ CRITICAL ERROR in download task for '{track_data.get('name')}': {e}") + traceback.print_exc() + with tasks_lock: + if task_id in download_tasks: + download_tasks[task_id]['status'] = 'failed' + + # Notify batch manager that this task completed (failed) + if batch_id: + _on_download_completed(batch_id, task_id, success=False) + +def _attempt_download_with_candidates(task_id, candidates, track): + """ + Attempts to download with fallback candidate logic (matches GUI's retry_parallel_download_with_fallback). + Returns True if successful, False if all candidates fail. + """ + # Sort candidates by confidence (best first) + candidates.sort(key=lambda r: r.confidence, reverse=True) + + with tasks_lock: + task = download_tasks.get(task_id) + if not task: + return False + used_sources = task.get('used_sources', set()) + + # Try each candidate until one succeeds (like GUI's fallback logic) + for candidate_index, candidate in enumerate(candidates): + # Check cancellation before each attempt + with tasks_lock: + if download_tasks[task_id]['status'] == 'cancelled': + print(f"❌ [Modal Worker] Task {task_id} cancelled during candidate {candidate_index + 1}") + return False + download_tasks[task_id]['current_candidate_index'] = candidate_index + + # Create source key to avoid duplicate attempts (like GUI) + source_key = f"{candidate.username}_{candidate.filename}" + if source_key in used_sources: + print(f"⏭️ [Modal Worker] Skipping already tried source: {source_key}") + continue + + print(f"🎯 [Modal Worker] Trying candidate {candidate_index + 1}/{len(candidates)}: {candidate.filename} (Confidence: {candidate.confidence:.2f})") + + try: + # Update task status to downloading + with tasks_lock: + if task_id in download_tasks: + download_tasks[task_id]['status'] = 'downloading' + download_tasks[task_id]['used_sources'].add(source_key) + + # Prepare download (using existing infrastructure) + spotify_artist_context = {'id': 'from_sync_modal', 'name': track.artists[0] if track.artists else 'Unknown', 'genres': []} + spotify_album_context = {'id': 'from_sync_modal', 'name': track.album, 'release_date': '', 'image_url': None} + download_payload = candidate.__dict__ + + username = download_payload.get('username') + filename = download_payload.get('filename') + size = download_payload.get('size', 0) + + if not username or not filename: + print(f"❌ [Modal Worker] Invalid candidate data: missing username or filename") + continue + + # Initiate download + download_id = asyncio.run(soulseek_client.download(username, filename, size)) + + if download_id: + # Store context for post-processing + context_key = f"{username}::{filename}" + with matched_context_lock: + matched_downloads_context[context_key] = { + "spotify_artist": spotify_artist_context, + "spotify_album": spotify_album_context, + "original_search_result": download_payload, + "is_album_download": False + } + + # Update task with successful download info + with tasks_lock: + if task_id in download_tasks: + download_tasks[task_id]['download_id'] = download_id + download_tasks[task_id]['username'] = username + download_tasks[task_id]['filename'] = filename + + print(f"βœ… [Modal Worker] Download started successfully for '{filename}'. Download ID: {download_id}") + return True # Success! + else: + print(f"❌ [Modal Worker] Failed to start download for '{filename}'") + # Reset status back to searching for next attempt + with tasks_lock: + if task_id in download_tasks: + download_tasks[task_id]['status'] = 'searching' + continue + + except Exception as e: + print(f"❌ [Modal Worker] Error attempting download for '{candidate.filename}': {e}") + # Reset status back to searching for next attempt + with tasks_lock: + if task_id in download_tasks: + download_tasks[task_id]['status'] = 'searching' + continue + + # All candidates failed + print(f"❌ [Modal Worker] All {len(candidates)} candidates failed for '{track.name}'") + return False + +@app.route('/api/playlists//download_missing', methods=['POST']) +def start_playlist_missing_downloads(playlist_id): + """ + This endpoint receives the list of missing tracks and manages them with batch processing + like the GUI, maintaining exactly 3 concurrent downloads. + """ + data = request.get_json() + missing_tracks = data.get('missing_tracks', []) + if not missing_tracks: + return jsonify({"success": False, "error": "No missing tracks provided"}), 400 + + try: + batch_id = str(uuid.uuid4()) + + # Create task queue for this batch + task_queue = [] + with tasks_lock: + # Initialize batch management + download_batches[batch_id] = { + 'queue': [], + 'active_count': 0, + 'max_concurrent': 3, + 'queue_index': 0 + } + + for i, track_entry in enumerate(missing_tracks): + task_id = str(uuid.uuid4()) + # Extract track data and original track index from frontend + track_data = track_entry.get('track', track_entry) # Support both old and new format + original_track_index = track_entry.get('track_index', i) # Use original index or fallback to enumeration + + download_tasks[task_id] = { + 'status': 'pending', + 'track_info': track_data, + 'playlist_id': playlist_id, + 'batch_id': batch_id, + 'track_index': original_track_index, # Use original playlist track index + 'download_id': None, + 'username': None + } + + # Add to batch queue instead of submitting immediately + download_batches[batch_id]['queue'].append(task_id) + + # Start the first batch of downloads (up to 3) + _start_next_batch_of_downloads(batch_id) + + return jsonify({"success": True, "batch_id": batch_id, "message": f"Queued {len(missing_tracks)} downloads for processing."}) + + except Exception as e: + print(f"❌ Error starting missing downloads: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@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. + """ + try: + 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 + batch_tasks.sort(key=lambda x: x['track_index']) + + return jsonify({"tasks": batch_tasks}) + + except Exception as e: + print(f"❌ Error getting batch status: {e}") + return jsonify({"error": str(e)}), 500 + +@app.route('/api/downloads/cancel_task', methods=['POST']) +def cancel_download_task(): + """Cancels a single, specific download task.""" + data = request.get_json() + task_id = data.get('task_id') + if not task_id: + return jsonify({"success": False, "error": "Missing task_id"}), 400 + + try: + with tasks_lock: + if task_id not in download_tasks: + return jsonify({"success": False, "error": "Task not found"}), 404 + + task = download_tasks[task_id] + task['status'] = 'cancelled' + + download_id = task.get('download_id') + username = task.get('username') + + # If the download has actually started on Soulseek, cancel it there too + if download_id and username: + try: + success = asyncio.run(soulseek_client.cancel_download(download_id, username, remove=True)) + return jsonify({"success": success}) + except Exception as e: + print(f"❌ Error cancelling Soulseek download: {e}") + return jsonify({"success": True, "note": "Task cancelled locally, but Soulseek cancellation failed"}) + else: + return jsonify({"success": True, "note": "Task cancelled before download started"}) + + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + # =============================== # == TRACK ANALYSIS API == # =============================== @@ -2542,48 +2987,53 @@ def cancel_analysis_task(task_id): @app.route('/api/tracks/download_missing', methods=['POST']) def start_missing_downloads(): - """Queue missing tracks for Soulseek download""" + """Legacy endpoint - redirect to new playlist-based endpoint""" data = request.get_json() missing_tracks = data.get('missing_tracks', []) if not missing_tracks: return jsonify({"success": False, "error": "No missing tracks provided"}), 400 + # Use a default playlist_id for legacy compatibility + playlist_id = "legacy_modal" + + # Call the new endpoint logic directly try: - queued_downloads = 0 - - for track_data in missing_tracks: - track = track_data.get('track', {}) - track_name = track.get('name', '') - artists = track.get('artists', []) - - if not track_name or not artists: - continue - - # Generate search query (simplified version of GUI logic) - artist_name = artists[0] if artists else 'Unknown Artist' - search_query = f"{track_name} {artist_name}".strip() - - print(f"πŸ“₯ Queuing download: '{search_query}'") + batch_id = str(uuid.uuid4()) + + # Create task queue for this batch + task_queue = [] + with tasks_lock: + # Initialize batch management + download_batches[batch_id] = { + 'queue': [], + 'active_count': 0, + 'max_concurrent': 3, + 'queue_index': 0 + } - # Queue download using existing soulseek client - try: - asyncio.run(soulseek_client.queue_search_and_download( - query=search_query, - preferred_format='flac' # Use user's preferred format - )) - queued_downloads += 1 - except Exception as e: - print(f"❌ Failed to queue download for '{search_query}': {e}") + for track_index, track_data in enumerate(missing_tracks): + task_id = str(uuid.uuid4()) + download_tasks[task_id] = { + 'status': 'pending', + 'track_info': track_data, + 'playlist_id': playlist_id, + 'batch_id': batch_id, + 'track_index': track_index, + 'download_id': None, + 'username': None + } + + # Add to batch queue instead of submitting immediately + download_batches[batch_id]['queue'].append(task_id) - return jsonify({ - "success": True, - "queued": queued_downloads, - "message": f"Queued {queued_downloads} downloads" - }) + # Start the first batch of downloads (up to 3) + _start_next_batch_of_downloads(batch_id) + + return jsonify({"success": True, "batch_id": batch_id, "message": f"Queued {len(missing_tracks)} downloads for processing."}) except Exception as e: - print(f"❌ Error queueing downloads: {e}") + print(f"❌ Error starting missing downloads: {e}") return jsonify({"success": False, "error": str(e)}), 500 # =============================== diff --git a/webui/static/script.js b/webui/static/script.js index f5348f74..c4806898 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -1609,6 +1609,11 @@ let currentPlaylistTracks = []; let analysisResults = []; let missingTracks = []; +// New variables for enhanced modal functionality +let currentDownloadBatchId = null; +let modalDownloadPoller = null; +let currentModalPlaylistId = null; + async function openDownloadMissingModal(playlistId) { console.log(`πŸ“₯ Opening Download Missing Tracks modal for playlist: ${playlistId}`); @@ -1641,6 +1646,7 @@ async function openDownloadMissingModal(playlistId) { } currentPlaylistTracks = tracks; + currentModalPlaylistId = playlistId; // Store playlist ID for new endpoints console.log(`βœ… Loaded ${tracks.length} tracks for analysis`); // Create or get modal @@ -1743,9 +1749,6 @@ async function openDownloadMissingModal(playlistId) { - @@ -1761,6 +1764,11 @@ async function openDownloadMissingModal(playlistId) { activeAnalysisTaskId = null; analysisResults = []; missingTracks = []; + currentDownloadBatchId = null; + if (modalDownloadPoller) { + clearInterval(modalDownloadPoller); + modalDownloadPoller = null; + } // Show modal modal.style.display = 'flex'; @@ -1783,6 +1791,12 @@ function closeDownloadMissingModal() { currentPlaylistTracks = []; analysisResults = []; missingTracks = []; + currentDownloadBatchId = null; + currentModalPlaylistId = null; + if (modalDownloadPoller) { + clearInterval(modalDownloadPoller); + modalDownloadPoller = null; + } } async function startTrackAnalysis() { @@ -1904,84 +1918,260 @@ function onAnalysisComplete(status) { console.log(`πŸ“Š Analysis results: ${analysisResults.length} total, ${missingTracks.length} missing`); - // Update UI for download phase + // Update UI and automatically start downloads if there are missing tracks document.getElementById('cancel-all-btn').style.display = 'none'; + if (missingTracks.length > 0) { - document.getElementById('start-downloads-btn').style.display = 'inline-block'; + console.log(`πŸš€ Analysis complete - automatically starting downloads for ${missingTracks.length} missing tracks`); + // Automatically initiate downloads - no button needed, just like the GUI + initiateMissingDownloads(); } else { showToast('All tracks were found in your library!', 'success'); document.getElementById('download-progress-text').textContent = 'No downloads needed - all tracks found!'; } } -async function startMissingDownloads() { +async function initiateMissingDownloads() { if (missingTracks.length === 0) { showToast('No missing tracks to download', 'info'); return; } - console.log(`⏬ Starting downloads for ${missingTracks.length} missing tracks`); + console.log(`⏬ Starting enhanced downloads for ${missingTracks.length} missing tracks`); try { - // Update UI - document.getElementById('start-downloads-btn').style.display = 'none'; - document.getElementById('cancel-all-btn').style.display = 'inline-block'; - document.getElementById('download-progress-text').textContent = 'Queueing downloads...'; + // Update UI - Cancel button should already be visible from analysis + document.getElementById('download-progress-text').textContent = 'Initiating downloads...'; - // Add cancel buttons to missing tracks + // Set initial status for all missing tracks for (const result of missingTracks) { + const statusElement = document.getElementById(`download-${result.track_index}`); const actionsElement = document.getElementById(`actions-${result.track_index}`); + if (statusElement) { + statusElement.textContent = '⏸️ Pending'; + statusElement.className = 'track-download-status download-pending'; + } if (actionsElement) { actionsElement.innerHTML = ``; } - - // Update download status - const statusElement = document.getElementById(`download-${result.track_index}`); - if (statusElement) { - statusElement.textContent = 'πŸ” Queueing...'; - statusElement.className = 'track-download-status download-searching'; - } } - // Queue downloads - const response = await fetch('/api/tracks/download_missing', { + // Call new playlist-specific endpoint + const response = await fetch(`/api/playlists/${currentModalPlaylistId}/download_missing`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - missing_tracks: missingTracks + missing_tracks: missingTracks.map(track => ({ + track: track.track, + track_index: track.track_index + })) // Include both track data and original index }) }); const data = await response.json(); if (!data.success) throw new Error(data.error); - console.log(`βœ… Queued ${data.queued} downloads`); - showToast(`Queued ${data.queued} downloads. Check the download queue for progress.`, 'success'); - - // Update UI - document.getElementById('download-progress-text').textContent = - `${data.queued} downloads queued. Check download queue for live progress.`; + // Store batch ID for polling + currentDownloadBatchId = data.batch_id; + console.log(`βœ… Started download batch: ${currentDownloadBatchId}`); - // Update download status for queued tracks - for (const result of missingTracks) { - const statusElement = document.getElementById(`download-${result.track_index}`); - if (statusElement) { - statusElement.textContent = 'πŸ“₯ Queued'; - statusElement.className = 'track-download-status download-searching'; - } - } + // Start live polling + startModalDownloadPolling(); - // Hide cancel button since downloads are now handled by the main download system - document.getElementById('cancel-all-btn').style.display = 'none'; + showToast(`Started downloads for ${missingTracks.length} tracks with live progress tracking.`, 'success'); } catch (error) { console.error('❌ Failed to start downloads:', error); showToast(`Failed to start downloads: ${error.message}`, 'error'); - // Reset UI - document.getElementById('start-downloads-btn').style.display = 'inline-block'; + // Reset UI on error - show the begin analysis button again + document.getElementById('begin-analysis-btn').style.display = 'inline-block'; document.getElementById('cancel-all-btn').style.display = 'none'; - document.getElementById('download-progress-text').textContent = 'Ready to download missing tracks'; + document.getElementById('download-progress-text').textContent = 'Download initiation failed'; + } +} + +function startModalDownloadPolling() { + if (!currentDownloadBatchId) { + console.warn('No batch ID available for polling'); + return; + } + + if (modalDownloadPoller) { + clearInterval(modalDownloadPoller); + } + + console.log(`πŸ“Š Starting download status polling for batch: ${currentDownloadBatchId}`); + + modalDownloadPoller = setInterval(async () => { + try { + const response = await fetch(`/api/playlists/${currentDownloadBatchId}/download_status`); + const data = await response.json(); + + if (data.error) { + console.error('Polling error:', data.error); + return; + } + + const tasks = data.tasks || []; + let completedCount = 0; + let failedCount = 0; + let totalTasks = tasks.length; + + // Update each track's status - but only for missing tracks + 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; + + switch (status) { + case 'pending': + statusElement.textContent = '⏸️ Pending'; + statusElement.className = 'track-download-status download-pending'; + actionsElement.innerHTML = ``; + break; + case 'searching': + statusElement.textContent = 'πŸ” Searching...'; + statusElement.className = 'track-download-status download-searching'; + actionsElement.innerHTML = ``; + break; + case 'downloading': + statusElement.textContent = '⏬ Downloading...'; + 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'; + statusElement.className = 'track-download-status download-complete'; + actionsElement.innerHTML = '-'; + completedCount++; + break; + case 'failed': + statusElement.textContent = '❌ Failed'; + statusElement.className = 'track-download-status download-failed'; + actionsElement.innerHTML = '-'; + failedCount++; + break; + case 'cancelled': + statusElement.textContent = '❌ Cancelled'; + statusElement.className = 'track-download-status download-cancelled'; + actionsElement.innerHTML = '-'; + failedCount++; + break; + default: + statusElement.textContent = `βšͺ ${status}`; + statusElement.className = 'track-download-status'; + break; + } + } + } + + // Update progress + 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 + if (completedCount + failedCount >= totalTasks && totalTasks > 0) { + clearInterval(modalDownloadPoller); + 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; + + // Fetch live download data from the downloads API + const response = await fetch('/api/downloads/status'); + const downloadData = await response.json(); + + if (downloadData.error) return; + + // Get all active and finished downloads + const allDownloads = {...(downloadData.active || {}), ...(downloadData.finished || {})}; + + // Update modal tracks that have active downloads + const modalRows = document.querySelectorAll('.download-missing-modal tr[data-track-index]'); + + for (const row of modalRows) { + const taskId = row.dataset.taskId; + if (!taskId) continue; + + // Find corresponding download by checking if filename/title matches + const trackName = row.querySelector('.track-name')?.textContent?.trim(); + if (!trackName) continue; + + // Search for matching download + for (const [downloadId, downloadInfo] of Object.entries(allDownloads)) { + const downloadTitle = downloadInfo.filename ? downloadInfo.filename.split(/[\\/]/).pop() : ''; + + // Simple matching - could be improved with better logic + if (downloadTitle && trackName && ( + downloadTitle.toLowerCase().includes(trackName.toLowerCase()) || + trackName.toLowerCase().includes(downloadTitle.toLowerCase()) + )) { + // Update the track with live download progress + const statusElement = row.querySelector('.track-download-status'); + const progress = downloadInfo.percentComplete || 0; + const state = downloadInfo.state || ''; + + if (statusElement && state.includes('InProgress') && progress > 0) { + statusElement.textContent = `⏬ Downloading... ${Math.round(progress)}%`; + statusElement.className = 'track-download-status download-downloading'; + } else if (statusElement && (state.includes('Completed') || state.includes('Succeeded'))) { + statusElement.textContent = 'βœ… Completed'; + statusElement.className = 'track-download-status download-complete'; + } + + break; // Found a match, stop searching + } + } + } + + } catch (error) { + // Silent fail - don't spam console during normal operation } } @@ -2044,19 +2234,81 @@ function resetToInitialState() { missingTracks = []; } -function cancelTrackDownload(trackIndex) { +async function cancelTrackDownload(trackIndex) { console.log(`πŸ›‘ Cancelling download for track ${trackIndex}`); - // Individual track cancellation would need to be implemented in the download system - // For now, just update the UI - const statusElement = document.getElementById(`download-${trackIndex}`); - const actionsElement = document.getElementById(`actions-${trackIndex}`); - if (statusElement) { - statusElement.textContent = '❌ Cancelled'; - statusElement.className = 'track-download-status download-failed'; - } - if (actionsElement) { - actionsElement.textContent = '-'; + try { + // Find the table row for this track + const row = document.querySelector(`tr[data-track-index="${trackIndex}"]`); + if (!row) { + console.error(`Could not find row for track index ${trackIndex}`); + return; + } + + // Get the task ID that was stored by the polling function + const taskId = row.dataset.taskId; + if (!taskId) { + console.warn(`No task ID found for track ${trackIndex}, cancelling locally only`); + // Update UI immediately for local cancellation + const statusElement = row.querySelector('.track-download-status'); + const actionsElement = row.querySelector('.track-actions'); + + if (statusElement) { + statusElement.textContent = '❌ Cancelled'; + statusElement.className = 'track-download-status download-cancelled'; + } + if (actionsElement) { + actionsElement.innerHTML = '-'; + } + return; + } + + // Update UI immediately to show cancellation in progress + const statusElement = row.querySelector('.track-download-status'); + const actionsElement = row.querySelector('.track-actions'); + + if (statusElement) { + statusElement.textContent = '⏹️ Cancelling...'; + statusElement.className = 'track-download-status download-cancelling'; + } + if (actionsElement) { + actionsElement.innerHTML = '-'; + } + + // Call the backend cancel endpoint + const response = await fetch('/api/downloads/cancel_task', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId }) + }); + + const data = await response.json(); + if (data.success) { + console.log(`βœ… Successfully cancelled task ${taskId}`); + // The polling function will update the UI with the final cancelled state + showToast('Download cancelled successfully', 'info'); + } else { + throw new Error(data.error || 'Failed to cancel download'); + } + + } catch (error) { + console.error('❌ Error cancelling download:', error); + showToast(`Failed to cancel download: ${error.message}`, 'error'); + + // Reset UI on error + const row = document.querySelector(`tr[data-track-index="${trackIndex}"]`); + if (row) { + const statusElement = row.querySelector('.track-download-status'); + const actionsElement = row.querySelector('.track-actions'); + + if (statusElement && statusElement.textContent === '⏹️ Cancelling...') { + statusElement.textContent = '❌ Cancel Failed'; + statusElement.className = 'track-download-status download-failed'; + } + if (actionsElement) { + actionsElement.innerHTML = ``; + } + } } } @@ -3376,6 +3628,13 @@ window.matchedDownloadTrack = matchedDownloadTrack; window.matchedDownloadAlbum = matchedDownloadAlbum; window.matchedDownloadAlbumTrack = matchedDownloadAlbumTrack; +// Download Missing Tracks Modal functions +window.openDownloadMissingModal = openDownloadMissingModal; +window.closeDownloadMissingModal = closeDownloadMissingModal; +window.startTrackAnalysis = startTrackAnalysis; +window.cancelAllOperations = cancelAllOperations; +window.cancelTrackDownload = cancelTrackDownload; + // APPEND THIS JAVASCRIPT SNIPPET (B) function initializeFilters() {