diff --git a/web_server.py b/web_server.py index 4a5bdc46..f5b8e697 100644 --- a/web_server.py +++ b/web_server.py @@ -13290,26 +13290,87 @@ def redownload_start(track_id): if row and row['file_path'] and delete_old: old_file_path = _resolve_library_file_path(row['file_path']) - # Build track data for the download worker - import uuid task_id = f"redownload_{track_id}_{int(time.time())}" batch_id = f"redownload_batch_{track_id}" - # Create a track dict compatible with the download worker + # Fetch full track details from the metadata source for pipeline parity + # This gives us track_number, disc_number, full album data + meta_source = metadata.get('_source', '') + meta_id = metadata.get('id', '') + full_track_details = None + full_album_data = None + + if meta_id: + try: + if meta_source == 'spotify' and spotify_client and spotify_client.is_authenticated(): + full_track_details = spotify_client.get_track_details(meta_id) + if full_track_details and full_track_details.get('album', {}).get('id'): + full_album_data = spotify_client.get_album(full_track_details['album']['id']) + elif meta_source == 'itunes': + from core.itunes_client import iTunesClient + _it = iTunesClient() + results = _it._lookup(id=meta_id, entity='song') + if results: + for r in results: + if r.get('wrapperType') == 'track': + full_track_details = r + break + elif meta_source == 'deezer': + _dz = _get_deezer_client() + full_track_details = _dz._api_get(f'track/{meta_id}') + except Exception as e: + logger.debug(f"[Redownload] Could not fetch full track details: {e}") + + # Build track data with full metadata for pipeline parity + track_number = None + disc_number = 1 + album_data = {'name': metadata.get('album', '')} + + if full_track_details: + if meta_source == 'spotify': + track_number = full_track_details.get('track_number') + disc_number = full_track_details.get('disc_number', 1) + album_raw = full_track_details.get('album', {}) + if album_raw: + album_images = album_raw.get('images', []) + album_data = { + 'id': album_raw.get('id', ''), + 'name': album_raw.get('name', metadata.get('album', '')), + 'release_date': album_raw.get('release_date', ''), + 'album_type': album_raw.get('album_type', 'album'), + 'total_tracks': album_raw.get('total_tracks', 0), + 'images': album_images, + 'image_url': album_images[0]['url'] if album_images else '', + } + elif meta_source == 'itunes': + track_number = full_track_details.get('trackNumber') + disc_number = full_track_details.get('discNumber', 1) + elif meta_source == 'deezer': + track_number = full_track_details.get('track_position') + disc_number = full_track_details.get('disk_number', 1) + track_data = { - 'id': metadata.get('id', ''), + 'id': meta_id, 'name': metadata.get('name', ''), 'artists': [{'name': metadata.get('artist', '')}], - 'album': {'name': metadata.get('album', '')}, + 'album': album_data, 'duration_ms': metadata.get('duration_ms', 0), + 'track_number': track_number, + 'disc_number': disc_number, + '_is_explicit_album_download': bool(full_album_data or (album_data.get('id'))), } + # Build explicit context if we have full album data + if full_album_data or album_data.get('id'): + track_data['_explicit_album_context'] = full_album_data if isinstance(full_album_data, dict) else album_data + track_data['_explicit_artist_context'] = {'name': metadata.get('artist', ''), 'id': '', 'genres': []} + # Create batch with tasks_lock: download_batches[batch_id] = { 'queue': [task_id], - 'queue_index': 0, - 'active_count': 0, + 'queue_index': 1, # Already past the first (only) item + 'active_count': 1, # One worker is about to start 'max_concurrent': 1, 'playlist_id': f'redownload_{track_id}', 'playlist_name': f"Redownload: {metadata.get('artist', '')} - {metadata.get('name', '')}", @@ -13317,6 +13378,8 @@ def redownload_start(track_id): 'total_tracks': 1, 'completed_count': 0, 'failed_count': 0, + 'cancelled_tracks': set(), + 'permanently_failed_tracks': [], 'force_download': True, 'auto_initiated': False, } @@ -14642,13 +14705,18 @@ def _clean_track_title(track_title: str, artist_name: str) -> str: return cleaned if cleaned else original def _extract_track_number_from_filename(filename: str, title: str = None) -> int: - """Extract track number from filename or title, returns 1 if not found.""" + """Extract track number from filename, returns 1 if not found. + Only matches numbers followed by a separator (dash, dot, space-dash) to avoid + picking up numbers that are part of artist/track names (e.g. '50 Cent').""" import re import os - text_to_check = f"{title or ''} {os.path.splitext(os.path.basename(filename))[0]}" - match = re.match(r'^\d{1,2}', text_to_check.strip()) + basename = os.path.splitext(os.path.basename(filename))[0] + # Match patterns like: "01 - Song", "01. Song", "01-Song", "1 Song" + match = re.match(r'^(\d{1,3})\s*[\-\.)\]]\s*', basename.strip()) if match: - return int(match.group(0)) + num = int(match.group(1)) + if 1 <= num <= 999: + return num return 1 def _search_track_in_album_context(original_search: dict, artist: dict) -> dict: diff --git a/webui/static/script.js b/webui/static/script.js index 6d53eed8..037e4ce3 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -43015,6 +43015,48 @@ function _renderRedownloadStep1(overlay, track, data) { `; modal.appendChild(footer); + // Wire up download button IMMEDIATELY (before streaming starts) + // so it works as soon as results appear + window._redownloadCandidates = []; + window._redownloadMetadata = selectedMeta; + document.getElementById('redownload-start-btn').addEventListener('click', async () => { + const checked = document.querySelector('input[name="source-choice"]:checked'); + if (!checked) { showToast('Select a download source', 'error'); return; } + const cand = window._redownloadCandidates[parseInt(checked.value)]; + if (!cand) { showToast('Invalid selection', 'error'); return; } + const deleteOld = document.getElementById('redownload-delete-old-check')?.checked ?? true; + + overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active')); + overlay.querySelector('.redownload-step[data-step="3"]').classList.add('active'); + + // Remove sticky footer for step 3 + const ft = overlay.querySelector('.redownload-sticky-footer'); + if (ft) ft.remove(); + + const body = document.getElementById('redownload-body'); + body.innerHTML = ` +
+
Downloading: ${_esc(cand.display_name)}
+
from ${_esc(cand.source_service === 'soulseek' ? cand.username : (cand.source_service || 'unknown'))}
+
+
Starting download...
+
+ `; + + try { + const res = await fetch(`/api/library/track/${track.id}/redownload/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metadata: window._redownloadMetadata, candidate: cand, delete_old_file: deleteOld }) + }); + const startData = await res.json(); + if (!startData.success) throw new Error(startData.error); + _pollRedownloadProgress(startData.task_id, overlay); + } catch (e) { + body.innerHTML = `
Download failed: ${_esc(e.message)}
`; + } + }); + _streamRedownloadSources(overlay, track, selectedMeta); }); } @@ -43067,6 +43109,7 @@ async function _streamRedownloadSources(overlay, track, metadata) { const startIdx = allCandidates.length; candidates.forEach((c, i) => { c._globalIdx = startIdx + i; }); allCandidates.push(...candidates); + window._redownloadCandidates = allCandidates; // Keep global ref updated for button handler // Find best overall candidate bestGlobalIdx = -1; @@ -43141,45 +43184,8 @@ async function _streamRedownloadSources(overlay, track, metadata) { loadingEl.innerHTML = '
No download sources found for this track.
'; } - // Store candidates for the download button + // Update the shared candidates array (button handler reads from window._redownloadCandidates) window._redownloadCandidates = allCandidates; - - // Wire up download button - const startBtn2 = document.getElementById('redownload-start-btn'); - if (startBtn2) { - startBtn2.addEventListener('click', async () => { - const checked = document.querySelector('input[name="source-choice"]:checked'); - if (!checked) { showToast('Select a download source', 'error'); return; } - const candidate = window._redownloadCandidates[parseInt(checked.value)]; - const deleteOld = document.getElementById('redownload-delete-old-check')?.checked ?? true; - - overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active')); - overlay.querySelector('.redownload-step[data-step="3"]').classList.add('active'); - - const body = document.getElementById('redownload-body'); - body.innerHTML = ` -
-
Downloading: ${_esc(candidate.display_name)}
-
from ${_esc(candidate.source_service === 'soulseek' ? candidate.username : (candidate.source_service || 'unknown'))}
-
-
Starting download...
-
- `; - - try { - const res = await fetch(`/api/library/track/${track.id}/redownload/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ metadata, candidate, delete_old_file: deleteOld }) - }); - const startData = await res.json(); - if (!startData.success) throw new Error(startData.error); - _pollRedownloadProgress(startData.task_id, overlay); - } catch (e) { - body.innerHTML = `
Download failed: ${_esc(e.message)}
`; - } - }); - } } /* _renderRedownloadStep2 removed — replaced by _streamRedownloadSources above */ @@ -43296,72 +43302,80 @@ if (false) { function _pollRedownloadProgress(taskId, overlay) { const bar = document.getElementById('redownload-progress-bar'); const status = document.getElementById('redownload-progress-status'); + let completed = false; const poll = setInterval(async () => { + if (completed) return; try { - const res = await fetch(`/api/active-processes`); - const data = await res.json(); - - // Find our task in download_tasks via batch - // Check if the task is in any batch - let taskStatus = null; - const procs = data.active_processes || []; - for (const proc of procs) { - if (proc.batch_id && proc.batch_id.includes(`redownload_batch_`)) { - taskStatus = proc; + // Poll real download progress from /api/downloads/status + const dlRes = await fetch('/api/downloads/status'); + const dlData = await dlRes.json(); + const transfers = dlData.transfers || []; + + // Find our transfer — match by checking active non-completed transfers + let bestTransfer = null; + for (const t of transfers) { + const st = (t.state || '').toLowerCase(); + if (st.includes('inprogress') || st.includes('queued') || st.includes('initializing')) { + bestTransfer = t; break; } } - // Simpler: just check the task status directly - // Since we can't easily get individual task status from active-processes, - // we'll use a simple timer-based approach - if (status) { - const elapsed = Math.round((Date.now() - _redownloadStartTime) / 1000); - status.textContent = `Downloading... (${elapsed}s)`; - } - if (bar) bar.style.width = `${Math.min(90, (Date.now() - _redownloadStartTime) / 600)}%`; - - } catch (e) { /* ignore poll errors */ } - }, 2000); + if (bestTransfer) { + const pct = bestTransfer.percentComplete || 0; + const transferred = bestTransfer.bytesTransferred || 0; + const total = bestTransfer.size || 0; + const transferredMB = (transferred / 1048576).toFixed(1); + const totalMB = (total / 1048576).toFixed(1); - _redownloadStartTime = Date.now(); + if (bar) bar.style.width = `${Math.min(95, pct)}%`; + if (status) { + if (total > 0) { + status.textContent = `Downloading... ${Math.round(pct)}% (${transferredMB} / ${totalMB} MB)`; + } else { + status.textContent = `Downloading... ${Math.round(pct)}%`; + } + } + } else { + // No active Soulseek transfer — might be a streaming source (Tidal/YouTube) + // or post-processing phase + if (bar && parseFloat(bar.style.width) < 50) { + bar.style.width = '60%'; + } + if (status) status.textContent = 'Processing...'; + } - // Also poll for completion via a simpler check - const completionCheck = setInterval(async () => { - try { - // Check if the task completed by trying to see if the batch is gone - const res = await fetch('/api/active-processes'); - const data = await res.json(); - const procs = data.active_processes || []; + // Check for completion — look for completed transfers or batch gone + const procRes = await fetch('/api/active-processes'); + const procData = await procRes.json(); + const procs = procData.active_processes || []; const ourBatch = procs.find(p => p.batch_id && p.batch_id.includes('redownload_batch_')); if (!ourBatch) { - // Batch is gone — either completed or failed + completed = true; clearInterval(poll); - clearInterval(completionCheck); if (bar) bar.style.width = '100%'; - if (status) status.textContent = 'Complete!'; + if (status) status.textContent = 'Complete! File replaced successfully.'; showToast('Track redownloaded successfully', 'success'); setTimeout(() => { overlay.remove(); - // Refresh enhanced view if (artistDetailPageState.enhancedData?.artist?.id) { loadEnhancedViewData(artistDetailPageState.enhancedData.artist.id); } - }, 1500); + }, 2000); } - } catch (e) { /* ignore */ } - }, 3000); + } catch (e) { /* ignore poll errors */ } + }, 1500); - // Safety timeout — 5 minutes max + // Safety timeout — 5 minutes setTimeout(() => { - clearInterval(poll); - clearInterval(completionCheck); - if (status) status.textContent = 'Download may still be in progress. Check the dashboard.'; + if (!completed) { + clearInterval(poll); + if (status) status.textContent = 'Download may still be in progress. Check the dashboard.'; + } }, 300000); } -let _redownloadStartTime = 0; async function deleteLibraryAlbum(albumId) { if (!await showConfirmDialog({ title: 'Delete Album', message: 'Delete this album and all its tracks from the library? (Files on disk are not affected)', confirmText: 'Delete', destructive: true })) return;