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;