pull/15/head
Broque Thomas 9 months ago
parent 632f45c68e
commit b9879c4375

@ -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/<playlist_id>/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/<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.
"""
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
# ===============================

@ -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) {
<button class="download-control-btn primary" id="begin-analysis-btn" onclick="startTrackAnalysis()">
Begin Analysis
</button>
<button class="download-control-btn primary" id="start-downloads-btn" onclick="startMissingDownloads()" style="display: none;">
Start Downloads
</button>
<button class="download-control-btn danger" id="cancel-all-btn" onclick="cancelAllOperations()" style="display: none;">
Cancel All
</button>
@ -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 = `<button class="cancel-track-btn" onclick="cancelTrackDownload(${result.track_index})">Cancel</button>`;
}
// 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 = `<button class="cancel-track-btn" onclick="cancelTrackDownload(${trackIndex})">Cancel</button>`;
break;
case 'searching':
statusElement.textContent = '🔍 Searching...';
statusElement.className = 'track-download-status download-searching';
actionsElement.innerHTML = `<button class="cancel-track-btn" onclick="cancelTrackDownload(${trackIndex})">Cancel</button>`;
break;
case 'downloading':
statusElement.textContent = '⏬ Downloading...';
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';
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 = `<button class="cancel-track-btn" onclick="cancelTrackDownload(${trackIndex})">Cancel</button>`;
}
}
}
}
@ -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() {

Loading…
Cancel
Save