Fix redownload pipeline — full parity, stuck batch, button timing

Pipeline parity: redownload/start now fetches full track details from
the selected metadata source (Spotify/iTunes/Deezer) for real
track_number, disc_number, and album context. Sets explicit album
context flags so post-processing uses the standard album download path.

Stuck batch fix: active_count was 0, decremented to -1 on completion,
so batch never detected as complete. Now initializes active_count=1
and queue_index=1 since we submit the worker directly.

Button timing: Download Selected handler wired up immediately before
streaming starts, reads from window._redownloadCandidates which
updates live as results arrive. No longer blocked by slow Soulseek.

Track number: _extract_track_number_from_filename requires separator
after digits so "50 Cent" is not parsed as track 50.

Progress: real download stats from /api/downloads/status. Handles
streaming sources showing "Processing..." when no transfer found.
pull/253/head
Broque Thomas 2 months ago
parent af98cb54c4
commit 0493f566df

@ -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:

@ -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 = `
<div class="redownload-progress">
<div class="redownload-progress-title">Downloading: ${_esc(cand.display_name)}</div>
<div class="redownload-progress-from">from ${_esc(cand.source_service === 'soulseek' ? cand.username : (cand.source_service || 'unknown'))}</div>
<div class="redownload-progress-bar-wrap"><div class="redownload-progress-bar" id="redownload-progress-bar"></div></div>
<div class="redownload-progress-status" id="redownload-progress-status">Starting download...</div>
</div>
`;
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 = `<div class="redownload-error">Download failed: ${_esc(e.message)}</div>`;
}
});
_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 = '<div class="rdl-src-col-empty">No download sources found for this track.</div>';
}
// 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 = `
<div class="redownload-progress">
<div class="redownload-progress-title">Downloading: ${_esc(candidate.display_name)}</div>
<div class="redownload-progress-from">from ${_esc(candidate.source_service === 'soulseek' ? candidate.username : (candidate.source_service || 'unknown'))}</div>
<div class="redownload-progress-bar-wrap"><div class="redownload-progress-bar" id="redownload-progress-bar"></div></div>
<div class="redownload-progress-status" id="redownload-progress-status">Starting download...</div>
</div>
`;
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 = `<div class="redownload-error">Download failed: ${_esc(e.message)}</div>`;
}
});
}
}
/* _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;

Loading…
Cancel
Save