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.
This commit is contained in:
parent
af98cb54c4
commit
0493f566df
2 changed files with 171 additions and 89 deletions
|
|
@ -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();
|
||||
// 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 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;
|
||||
// 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 (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);
|
||||
|
||||
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...';
|
||||
}
|
||||
if (bar) bar.style.width = `${Math.min(90, (Date.now() - _redownloadStartTime) / 600)}%`;
|
||||
|
||||
} catch (e) { /* ignore poll errors */ }
|
||||
}, 2000);
|
||||
|
||||
_redownloadStartTime = Date.now();
|
||||
|
||||
// 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…
Reference in a new issue