// WING IT β Download without metadata discovery
// ==================================================================================
// Blocklist (Phase 2b): when a download is refused because the artist/album/track
// is on the blocklist, the backend returns {blocked:true,...}. Ask the user
// whether to override; callers re-POST with ignore_blocklist:true on confirm.
function confirmBlockedDownload(data) {
const what = data.blocked_entity_type || 'item';
const name = data.blocked_name || 'this item';
return confirm(`"${name}" is on your blocklist (${what} blocked).\n\nDownload anyway?`);
}
function _toggleWingItDropdown(btn, urlHash) {
// Remove any existing dropdown
const existing = document.querySelector('.wing-it-dropdown.visible');
if (existing) { existing.classList.remove('visible'); setTimeout(() => existing.remove(), 150); return; }
const wrap = btn.closest('.wing-it-wrap');
if (!wrap) return;
const dropdown = document.createElement('div');
dropdown.className = 'wing-it-dropdown';
dropdown.innerHTML = `
β¬οΈ
Download
Raw names
π
Sync to Server
Best-effort
`;
dropdown.querySelectorAll('.wing-it-dropdown-item').forEach(item => {
item.addEventListener('click', () => {
dropdown.classList.remove('visible');
setTimeout(() => dropdown.remove(), 150);
const action = item.dataset.action;
if (action === 'download') {
_wingItAction(urlHash, 'download');
} else {
_wingItAction(urlHash, 'sync');
}
});
});
// Flip dropdown direction if button is in the top portion of viewport
const btnRect = btn.getBoundingClientRect();
if (btnRect.top < 200) dropdown.classList.add('flip-down');
wrap.appendChild(dropdown);
requestAnimationFrame(() => dropdown.classList.add('visible'));
// Close on outside click
setTimeout(() => {
const closeHandler = e => {
if (!dropdown.contains(e.target) && e.target !== btn) {
dropdown.classList.remove('visible');
setTimeout(() => dropdown.remove(), 150);
document.removeEventListener('click', closeHandler);
}
};
document.addEventListener('click', closeHandler);
}, 50);
}
function _wingItAction(urlHash, action) {
if (urlHash) {
// Called from a modal β use _wingItFromModal logic
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash] || {};
const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || [];
const name = state.playlistName || state.name || state.playlist?.name || 'Playlist';
const isTidal = state.is_tidal_playlist;
const isQobuz = state.is_qobuz_playlist;
const isLB = state.is_listenbrainz_playlist;
const isBeatport = state.is_beatport_playlist;
const isDeezer = state.is_deezer_playlist;
const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isQobuz ? 'Qobuz' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube';
if (!tracks.length) {
showToast('No tracks available for Wing It', 'error');
return;
}
if (action === 'sync') {
// Sync inline β keep modal open
_wingItSyncFromModal(urlHash, tracks, name, isLB);
} else {
// Download β close modal, open download modal
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (modal) modal.remove();
const overlay = document.getElementById(`youtube-discovery-overlay-${urlHash}`);
if (overlay) overlay.remove();
wingItDownload(tracks, name, source, null, true);
}
}
}
async function _wingItSyncFromModal(urlHash, tracks, name, isLB) {
showToast('Starting Wing It sync...', 'info');
updateYouTubeModalButtons(urlHash, 'syncing');
try {
const syncTracks = tracks.map((t, i) => {
let artists = t.artists || [];
if (!Array.isArray(artists)) artists = [{ name: String(artists) }];
return {
id: t.id || t.source_track_id || `wing_it_${i}`,
name: t.name || t.track_name || 'Unknown',
artists: artists.map(a => typeof a === 'string' ? { name: a } : a),
album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' },
duration_ms: t.duration_ms || 0,
};
});
const res = await fetch('/api/wing-it/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tracks: syncTracks, playlist_name: name })
});
const data = await res.json();
if (data.error) {
showToast(`Sync failed: ${data.error}`, 'error');
updateYouTubeModalButtons(urlHash, 'discovered');
return;
}
if (isLB) {
const state = listenbrainzPlaylistStates[urlHash];
if (state) state.syncPlaylistId = data.sync_playlist_id;
startListenBrainzSyncPolling(urlHash, data.sync_playlist_id);
} else {
startYouTubeSyncPolling(urlHash, data.sync_playlist_id);
}
} catch (e) {
showToast('Sync failed: ' + e.message, 'error');
updateYouTubeModalButtons(urlHash, 'discovered');
}
}
async function wingItDownload(tracks, playlistName, source = 'playlist', cardIdentifier = null, skipConfirm = false) {
if (!tracks || tracks.length === 0) {
showToast('No tracks to download', 'error');
return;
}
if (!skipConfirm) {
// Show choice: Download or Sync (for LB card button which doesn't have dropdown)
const choice = await _showWingItChoiceDialog(tracks.length, source);
if (!choice) return;
if (choice === 'sync') {
await _wingItSync(tracks, playlistName, source, cardIdentifier);
return;
}
}
// Normalize tracks to Spotify-compatible format
const formattedTracks = tracks.map(t => {
// Handle various artist formats
let artists = [];
if (t.artists) {
if (Array.isArray(t.artists)) {
artists = t.artists.map(a => typeof a === 'string' ? { name: a } : a);
} else if (typeof t.artists === 'string') {
artists = [{ name: t.artists }];
}
} else if (t.artist_name) {
artists = [{ name: t.artist_name }];
} else if (t.artist) {
artists = [{ name: t.artist }];
}
if (artists.length === 0) artists = [{ name: 'Unknown' }];
// Handle album
let album = { name: '' };
if (t.album) {
album = typeof t.album === 'string' ? { name: t.album } : t.album;
} else if (t.album_name) {
album = { name: t.album_name };
}
return {
id: t.id || t.source_track_id || `wing_it_${Date.now()}_${Math.random()}`,
name: t.name || t.track_name || 'Unknown Track',
artists: artists,
duration_ms: t.duration_ms || 0,
album: album,
};
});
const virtualPlaylistId = `wing_it_${Date.now()}`;
// Store wing_it flag BEFORE opening the modal
youtubePlaylistStates[virtualPlaylistId] = {
wing_it: true,
tracks: formattedTracks,
};
await openDownloadMissingModalForYouTube(virtualPlaylistId, `β‘ ${playlistName}`, formattedTracks);
// Pre-check the Force Download toggle
setTimeout(() => {
const forceToggle = document.getElementById(`force-download-all-${virtualPlaylistId}`);
if (forceToggle && !forceToggle.checked) forceToggle.checked = true;
}, 800);
}
function _showWingItChoiceDialog(trackCount, source) {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;';
const close = val => { overlay.remove(); resolve(val); };
overlay.onclick = e => { if (e.target === overlay) close(null); };
overlay.innerHTML = `
${trackCount} track${trackCount !== 1 ? 's' : ''} from ${source}. No metadata discovery β uses raw names. Failed tracks won't be added to wishlist.
β¬οΈ
Download
Search and download each track using raw names.
π
Sync to Server
Mirror playlist and sync to your media server. Best-effort matching.
`;
overlay.querySelectorAll('.smart-delete-option').forEach(btn => {
btn.addEventListener('click', () => close(btn.dataset.choice));
});
overlay.querySelector('.smart-delete-close').addEventListener('click', () => close(null));
const escH = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escH); close(null); } };
document.addEventListener('keydown', escH);
document.body.appendChild(overlay);
});
}
async function _wingItSync(tracks, playlistName, source, cardIdentifier = null) {
try {
showToast('Syncing playlist to server...', 'info');
// Format tracks for the sync endpoint
const syncTracks = tracks.map((t, i) => {
let artists = t.artists || [];
if (!Array.isArray(artists)) artists = [{ name: String(artists) }];
return {
id: t.id || t.source_track_id || `wing_it_${i}`,
name: t.name || t.track_name || 'Unknown',
artists: artists.map(a => typeof a === 'string' ? { name: a } : a),
album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' },
duration_ms: t.duration_ms || 0,
artist_name: t.artist_name,
};
});
const res = await fetch('/api/wing-it/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tracks: syncTracks, playlist_name: playlistName })
});
const data = await res.json();
if (data.error) {
showToast(`Sync failed: ${data.error}`, 'error');
return;
}
// Show inline sync status on the card (same display as normal sync)
const playlistId = cardIdentifier ? `discover-lb-playlist-${cardIdentifier}` : null;
if (playlistId) {
const statusDisplay = document.getElementById(`${playlistId}-sync-status`);
if (statusDisplay) statusDisplay.style.display = 'block';
// Disable sync/wing-it buttons during sync
const syncBtn = document.getElementById(`${playlistId}-sync-btn`);
if (syncBtn) { syncBtn.disabled = true; syncBtn.style.opacity = '0.5'; }
}
// Poll for sync progress β update inline display
if (data.sync_playlist_id) {
_pollWingItSyncProgress(data.sync_playlist_id, playlistName, playlistId);
}
} catch (e) {
showToast('Sync failed: ' + e.message, 'error');
}
}
function _pollWingItSyncProgress(syncPlaylistId, playlistName, cardPlaylistId) {
const poll = setInterval(async () => {
try {
const res = await fetch(`/api/sync/status/${syncPlaylistId}`);
const data = await res.json();
// Update inline status display if we have a card
if (cardPlaylistId && data.progress) {
const p = data.progress;
const total = p.total_tracks || p.total || 0;
const matched = p.matched_tracks || p.matched || 0;
const failed = p.failed_tracks || p.failed || 0;
const totalEl = document.getElementById(`${cardPlaylistId}-sync-total`);
const matchedEl = document.getElementById(`${cardPlaylistId}-sync-matched`);
const failedEl = document.getElementById(`${cardPlaylistId}-sync-failed`);
const pctEl = document.getElementById(`${cardPlaylistId}-sync-percentage`);
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
if (pctEl) pctEl.textContent = total > 0 ? Math.round((matched / total) * 100) : 0;
}
if (data.status === 'finished' || data.status === 'complete' || data.status === 'error') {
clearInterval(poll);
const matched = data.progress?.matched_tracks || data.progress?.matched || 0;
const total = data.progress?.total_tracks || data.progress?.total || 0;
if (data.status === 'error') {
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
} else {
showToast(`β‘ Wing It sync complete β "${playlistName}" created on server (${matched}/${total} tracks matched)`, 'success');
}
// Update card status display to show completion
if (cardPlaylistId) {
const statusLabel = document.querySelector(`#${cardPlaylistId}-sync-status .sync-status-label span:last-child`);
if (statusLabel) statusLabel.textContent = `Sync complete β ${matched}/${total} matched`;
const syncIcon = document.querySelector(`#${cardPlaylistId}-sync-status .sync-icon`);
if (syncIcon) syncIcon.textContent = 'β';
}
}
} catch (e) { /* ignore poll errors */ }
}, 2000);
// Safety timeout
setTimeout(() => clearInterval(poll), 180000);
}
async function _wingItFromModal(urlHash) {
// Extract tracks from the discovery modal state β tracks can be in various locations
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash] || {};
const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || [];
const name = state.playlistName || state.name || state.playlist?.name || 'Playlist';
const isTidal = state.is_tidal_playlist;
const isQobuz = state.is_qobuz_playlist;
const isLB = state.is_listenbrainz_playlist;
const isBeatport = state.is_beatport_playlist;
const isDeezer = state.is_deezer_playlist;
const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isQobuz ? 'Qobuz' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube';
if (!tracks.length) {
showToast('No tracks available for Wing It', 'error');
return;
}
const choice = await _showWingItChoiceDialog(tracks.length, source);
if (!choice) return;
if (choice === 'sync') {
// Sync inline β keep modal open, show progress in modal
showToast('Starting Wing It sync...', 'info');
updateYouTubeModalButtons(urlHash, 'syncing');
try {
// Format and send sync request
const syncTracks = tracks.map((t, i) => {
let artists = t.artists || [];
if (!Array.isArray(artists)) artists = [{ name: String(artists) }];
return {
id: t.id || t.source_track_id || `wing_it_${i}`,
name: t.name || t.track_name || 'Unknown',
artists: artists.map(a => typeof a === 'string' ? { name: a } : a),
album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' },
duration_ms: t.duration_ms || 0,
};
});
const res = await fetch('/api/wing-it/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tracks: syncTracks, playlist_name: name })
});
const data = await res.json();
if (data.error) {
showToast(`Sync failed: ${data.error}`, 'error');
updateYouTubeModalButtons(urlHash, 'discovered');
return;
}
// Use the same sync polling as normal sync β works for any source
if (isLB) {
if (state) state.syncPlaylistId = data.sync_playlist_id;
startListenBrainzSyncPolling(urlHash, data.sync_playlist_id);
} else {
startYouTubeSyncPolling(urlHash, data.sync_playlist_id);
}
} catch (e) {
showToast('Sync failed: ' + e.message, 'error');
updateYouTubeModalButtons(urlHash, 'discovered');
}
return;
}
// choice === 'download' β close modal and open download modal
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (modal) modal.remove();
const overlay = document.getElementById(`youtube-discovery-overlay-${urlHash}`);
if (overlay) overlay.remove();
wingItDownload(tracks, name, source);
}
async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks, artist = null, album = null) {
showLoadingOverlay('Loading YouTube playlist...');
// Check if a process is already active for this virtual playlist
if (activeDownloadProcesses[virtualPlaylistId]) {
console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`);
const process = activeDownloadProcesses[virtualPlaylistId];
if (process.modalElement) {
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
}
if (typeof refreshOrganizePreferenceForDownloadModal === 'function') {
await refreshOrganizePreferenceForDownloadModal(virtualPlaylistId);
}
hideLoadingOverlay(); // Hide overlay when reopening existing modal
return;
}
console.log(`π₯ Opening Download Missing Tracks modal for YouTube playlist: ${virtualPlaylistId}`);
// Create virtual playlist object for compatibility with existing modal logic
const virtualPlaylist = {
id: virtualPlaylistId,
name: playlistName,
track_count: spotifyTracks.length
};
// Store the tracks in the cache for the modal to use
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
currentPlaylistTracks = spotifyTracks;
currentModalPlaylistId = virtualPlaylistId;
let modal = document.createElement('div');
modal.id = `download-missing-modal-${virtualPlaylistId}`;
modal.className = 'download-missing-modal';
modal.style.display = 'none';
document.body.appendChild(modal);
// Register the new process in our global state tracker using the same structure as Spotify
activeDownloadProcesses[virtualPlaylistId] = {
status: 'idle',
modalElement: modal,
poller: null,
batchId: null,
playlist: virtualPlaylist,
tracks: spotifyTracks,
artist: artist, // β
Store artist context
album: album // β
Store album context
};
// Generate hero section with dynamic source detection
const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' :
virtualPlaylistId.startsWith('tidal_') ? 'Tidal' :
virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' :
virtualPlaylistId.startsWith('spotify_public_') ? 'Spotify' :
virtualPlaylistId.startsWith('spotify:') ? 'Spotify' :
virtualPlaylistId.startsWith('discover_') ? 'SoulSync' :
virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' :
virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' :
virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' :
virtualPlaylistId.startsWith('decade_') ? 'SoulSync' :
virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
'YouTube';
// Store metadata for discover download sidebar (will be added when Begin Analysis is clicked)
if (source === 'SoulSync' || virtualPlaylistId.startsWith('discover_lb_') || virtualPlaylistId.startsWith('listenbrainz_') || virtualPlaylistId.startsWith('wing_it_')) {
// Extract image URL from album context or first track's album cover
let imageUrl = null;
if (album && album.images && album.images.length > 0) {
imageUrl = album.images[0].url;
} else if (spotifyTracks && spotifyTracks.length > 0) {
const firstTrack = spotifyTracks[0];
if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) {
imageUrl = firstTrack.album.images[0].url;
}
}
// Store in process for later use when Begin Analysis is clicked
activeDownloadProcesses[virtualPlaylistId].discoverMetadata = {
imageUrl: imageUrl,
type: album ? 'album' : 'playlist' // β
Use 'album' if album context provided
};
}
// CRITICAL FIX: Use album context for discover_album playlists
const isDiscoverAlbum = virtualPlaylistId.startsWith('discover_album_') || virtualPlaylistId.startsWith('discover_cache_') || virtualPlaylistId.startsWith('seasonal_album_') || virtualPlaylistId.startsWith('spotify_library_');
const heroContext = isDiscoverAlbum && album && artist ? {
type: 'album',
artist: {
...artist,
name: artist.name,
id: artist.id || artist.artist_id || null,
source: artist.source || album.source || null,
image_url: artist.image_url || null
},
album: {
name: album.name,
album_type: album.album_type || 'album',
images: album.images || []
},
trackCount: spotifyTracks.length,
playlistId: virtualPlaylistId
} : {
type: 'playlist',
playlist: { name: playlistName, owner: source },
trackCount: spotifyTracks.length,
playlistId: virtualPlaylistId
};
// Use the exact same modal HTML structure as the existing Spotify modal
modal.innerHTML = `
π Library Analysis
Ready to start
β¬ Downloads
Waiting for analysis
`;
applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length);
modal.style.display = 'flex';
hideLoadingOverlay();
}
async function closeDownloadMissingModal(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) {
// If somehow called without a process, try to find and remove the element
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
if (modal && modal.parentElement) {
modal.parentElement.removeChild(modal);
}
return;
}
// If the process is running, just hide the modal.
// If it's idle, complete, or cancelled, perform a full cleanup.
if (process.status === 'running') {
console.log(`Hiding active download modal for playlist ${playlistId}.`);
process.modalElement.style.display = 'none';
// Track wishlist modal state changes
if (playlistId === 'wishlist') {
WishlistModalState.setUserClosed(); // User manually closed during processing
console.log('π± [Modal State] User manually closed wishlist modal during processing');
}
} else {
console.log(`Closing and cleaning up download modal for playlist ${playlistId}.`);
// Reset YouTube playlist phase to 'discovered' when modal is closed after completion
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'discovered');
// Also update mirrored playlist card if applicable
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'discovered');
}
// Update backend state to prevent rehydration issues on page refresh (similar to Tidal fix)
try {
const response = await fetch(`/api/youtube/update_phase/${urlHash}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
phase: 'discovered'
})
});
if (response.ok) {
console.log(`β
[Modal Close] Updated backend phase for YouTube playlist ${urlHash} to 'discovered'`);
} else {
console.warn(`β οΈ [Modal Close] Failed to update backend phase for YouTube playlist ${urlHash}`);
}
} catch (error) {
console.error(`β [Modal Close] Error updating backend phase for YouTube playlist ${urlHash}:`, error);
}
}
// Reset Beatport chart phase to 'discovered' when modal is closed
if (playlistId.startsWith('beatport_')) {
const urlHash = playlistId.replace('beatport_', '');
const state = youtubePlaylistStates[urlHash];
if (state && state.is_beatport_playlist) {
console.log(`π§Ή [Modal Close] Processing Beatport chart close: playlistId="${playlistId}", urlHash="${urlHash}"`);
const chartHash = state.beatport_chart_hash || urlHash;
// Reset to discovered phase (unless download actually started and completed)
if (state.phase !== 'download_complete') {
updateBeatportCardPhase(chartHash, 'discovered');
state.phase = 'discovered';
// Update Beatport chart state
if (beatportChartStates[chartHash]) {
beatportChartStates[chartHash].phase = 'discovered';
}
// Update backend state
try {
await fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
console.log(`β
[Modal Close] Updated backend phase for Beatport chart ${chartHash} to 'discovered'`);
} catch (error) {
console.error(`β [Modal Close] Error updating backend phase for Beatport chart ${chartHash}:`, error);
}
}
}
}
// Enhanced Tidal playlist state management (based on GUI sync.py patterns)
if (playlistId.startsWith('tidal_')) {
const tidalPlaylistId = playlistId.replace('tidal_', '');
console.log(`π§Ή [Modal Close] Processing Tidal playlist close: playlistId="${playlistId}", tidalPlaylistId="${tidalPlaylistId}"`);
console.log(`π§Ή [Modal Close] Current Tidal state:`, tidalPlaylistStates[tidalPlaylistId]);
// Clear download-specific state but preserve discovery results (like GUI closeEvent)
if (tidalPlaylistStates[tidalPlaylistId]) {
const currentPhase = tidalPlaylistStates[tidalPlaylistId].phase;
console.log(`π§Ή [Modal Close] Current phase before reset: ${currentPhase}`);
// Preserve discovery data for future use (like GUI modal behavior)
const preservedData = {
playlist: tidalPlaylistStates[tidalPlaylistId].playlist,
discovery_results: tidalPlaylistStates[tidalPlaylistId].discovery_results,
spotify_matches: tidalPlaylistStates[tidalPlaylistId].spotify_matches,
discovery_progress: tidalPlaylistStates[tidalPlaylistId].discovery_progress,
convertedSpotifyPlaylistId: tidalPlaylistStates[tidalPlaylistId].convertedSpotifyPlaylistId
};
// Clear download-specific state
delete tidalPlaylistStates[tidalPlaylistId].download_process_id;
delete tidalPlaylistStates[tidalPlaylistId].phase;
// Restore preserved data and set to discovered phase
Object.assign(tidalPlaylistStates[tidalPlaylistId], preservedData);
tidalPlaylistStates[tidalPlaylistId].phase = 'discovered';
console.log(`π§Ή [Modal Close] Reset Tidal playlist ${tidalPlaylistId} - cleared download state, preserved discovery data`);
console.log(`π§Ή [Modal Close] New phase after reset: ${tidalPlaylistStates[tidalPlaylistId].phase}`);
} else {
console.error(`β [Modal Close] No Tidal state found for playlistId: ${tidalPlaylistId}`);
}
updateTidalCardPhase(tidalPlaylistId, 'discovered');
console.log(`π [Modal Close] Reset Tidal playlist ${tidalPlaylistId} to discovered phase`);
console.log(`π [Modal Close] Expected button text for discovered phase: "${getActionButtonText('discovered')}"`);
// Update backend state to prevent rehydration issues on page refresh
try {
const response = await fetch(`/api/tidal/update_phase/${tidalPlaylistId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
phase: 'discovered'
})
});
if (response.ok) {
console.log(`β
[Modal Close] Updated backend phase for Tidal playlist ${tidalPlaylistId} to 'discovered'`);
} else {
console.warn(`β οΈ [Modal Close] Failed to update backend phase for Tidal playlist ${tidalPlaylistId}`);
}
} catch (error) {
console.error(`β [Modal Close] Error updating backend phase for Tidal playlist ${tidalPlaylistId}:`, error);
}
}
// Reset ListenBrainz playlist phase to 'discovered' when modal is closed
if (playlistId.startsWith('listenbrainz_')) {
const playlistMbid = playlistId.replace('listenbrainz_', '');
console.log(`π§Ή [Modal Close] Processing ListenBrainz playlist close: playlistId="${playlistId}", mbid="${playlistMbid}"`);
// Clear download-specific state but preserve discovery results
if (listenbrainzPlaylistStates[playlistMbid]) {
const currentPhase = listenbrainzPlaylistStates[playlistMbid].phase;
console.log(`π§Ή [Modal Close] Current phase before reset: ${currentPhase}`);
// Reset to discovered phase (unless download actually completed successfully)
if (currentPhase !== 'download_complete') {
// Clear download-specific fields
delete listenbrainzPlaylistStates[playlistMbid].download_process_id;
delete listenbrainzPlaylistStates[playlistMbid].convertedSpotifyPlaylistId;
// Set back to discovered
listenbrainzPlaylistStates[playlistMbid].phase = 'discovered';
// Update backend state
try {
await fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
console.log(`β
[Modal Close] Updated backend phase for ListenBrainz playlist ${playlistMbid} to 'discovered'`);
} catch (error) {
console.error(`β [Modal Close] Error updating backend phase for ListenBrainz playlist ${playlistMbid}:`, error);
}
console.log(`π [Modal Close] Reset ListenBrainz playlist ${playlistMbid} to discovered phase`);
}
} else {
console.error(`β [Modal Close] No ListenBrainz state found for mbid: ${playlistMbid}`);
}
}
// Reset Spotify Public playlist phase to 'discovered' when modal is closed
if (playlistId.startsWith('spotify_public_')) {
const spUrlHash = playlistId.replace('spotify_public_', '');
console.log(`π§Ή [Modal Close] Processing Spotify Public playlist close: playlistId="${playlistId}", urlHash="${spUrlHash}"`);
if (spotifyPublicPlaylistStates[spUrlHash]) {
const currentPhase = spotifyPublicPlaylistStates[spUrlHash].phase;
console.log(`π§Ή [Modal Close] Current phase before reset: ${currentPhase}`);
const preservedData = {
playlist: spotifyPublicPlaylistStates[spUrlHash].playlist,
discovery_results: spotifyPublicPlaylistStates[spUrlHash].discovery_results,
spotify_matches: spotifyPublicPlaylistStates[spUrlHash].spotify_matches,
discovery_progress: spotifyPublicPlaylistStates[spUrlHash].discovery_progress,
convertedSpotifyPlaylistId: spotifyPublicPlaylistStates[spUrlHash].convertedSpotifyPlaylistId
};
delete spotifyPublicPlaylistStates[spUrlHash].download_process_id;
delete spotifyPublicPlaylistStates[spUrlHash].phase;
Object.assign(spotifyPublicPlaylistStates[spUrlHash], preservedData);
spotifyPublicPlaylistStates[spUrlHash].phase = 'discovered';
console.log(`π§Ή [Modal Close] Reset Spotify Public playlist ${spUrlHash} - cleared download state, preserved discovery data`);
}
updateSpotifyPublicCardPhase(spUrlHash, 'discovered');
try {
await fetch(`/api/spotify-public/update_phase/${spUrlHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
console.log(`β
[Modal Close] Updated backend phase for Spotify Public playlist ${spUrlHash} to 'discovered'`);
} catch (error) {
console.error(`β [Modal Close] Error updating backend phase for Spotify Public playlist ${spUrlHash}:`, error);
}
}
// Reset Deezer playlist phase to 'discovered' when modal is closed
if (playlistId.startsWith('deezer_')) {
const deezerPlaylistId = playlistId.replace('deezer_', '');
console.log(`π§Ή [Modal Close] Processing Deezer playlist close: playlistId="${playlistId}", deezerPlaylistId="${deezerPlaylistId}"`);
if (deezerPlaylistStates[deezerPlaylistId]) {
const currentPhase = deezerPlaylistStates[deezerPlaylistId].phase;
console.log(`π§Ή [Modal Close] Current phase before reset: ${currentPhase}`);
const preservedData = {
playlist: deezerPlaylistStates[deezerPlaylistId].playlist,
discovery_results: deezerPlaylistStates[deezerPlaylistId].discovery_results,
spotify_matches: deezerPlaylistStates[deezerPlaylistId].spotify_matches,
discovery_progress: deezerPlaylistStates[deezerPlaylistId].discovery_progress,
convertedSpotifyPlaylistId: deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId
};
delete deezerPlaylistStates[deezerPlaylistId].download_process_id;
delete deezerPlaylistStates[deezerPlaylistId].phase;
Object.assign(deezerPlaylistStates[deezerPlaylistId], preservedData);
deezerPlaylistStates[deezerPlaylistId].phase = 'discovered';
console.log(`π§Ή [Modal Close] Reset Deezer playlist ${deezerPlaylistId} - cleared download state, preserved discovery data`);
}
updateDeezerCardPhase(deezerPlaylistId, 'discovered');
try {
await fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
console.log(`β
[Modal Close] Updated backend phase for Deezer playlist ${deezerPlaylistId} to 'discovered'`);
} catch (error) {
console.error(`β [Modal Close] Error updating backend phase for Deezer playlist ${deezerPlaylistId}:`, error);
}
}
// Clear wishlist modal state when modal is fully closed
if (playlistId === 'wishlist') {
WishlistModalState.clear(); // Clear all tracking since modal is fully closed
console.log('π± [Modal State] Cleared wishlist modal state on full close');
}
// Clean up artist download if this is an artist album playlist
if (playlistId.startsWith('artist_album_')) {
console.log(`π§Ή [MODAL CLOSE] Cleaning up artist download for completed modal: ${playlistId}`);
cleanupArtistDownload(playlistId);
console.log(`β
[MODAL CLOSE] Artist download cleanup completed for: ${playlistId}`);
}
// Clean up search download if this is an enhanced search playlist
if (playlistId.startsWith('enhanced_search_')) {
console.log(`π§Ή [MODAL CLOSE] Cleaning up search download for completed modal: ${playlistId}`);
cleanupSearchDownload(playlistId);
console.log(`β
[MODAL CLOSE] Search download cleanup completed for: ${playlistId}`);
}
// Clean up Beatport download if this is a beatport chart or release playlist
if (playlistId.startsWith('beatport_chart_') || playlistId.startsWith('beatport_release_')) {
console.log(`π§Ή [MODAL CLOSE] Cleaning up Beatport download for completed modal: ${playlistId}`);
cleanupBeatportDownload(playlistId);
console.log(`β
[MODAL CLOSE] Beatport download cleanup completed for: ${playlistId}`);
}
// Remove from discover download sidebar if this is a discover page download
if (discoverDownloads && discoverDownloads[playlistId]) {
console.log(`π§Ή [MODAL CLOSE] Removing discover download bubble: ${playlistId}`);
removeDiscoverDownload(playlistId);
console.log(`β
[MODAL CLOSE] Discover download bubble removed for: ${playlistId}`);
}
// Automatic cleanup and server operations after successful downloads
await handlePostDownloadAutomation(playlistId, process);
cleanupDownloadProcess(playlistId);
}
}
/**
* Extract unique album cover images from tracks
*/
function extractUniqueCoverImages(tracks, maxCovers = 20) {
const uniqueCovers = new Set();
const covers = [];
for (const track of tracks) {
if (covers.length >= maxCovers) break;
let coverUrl = null;
let spotifyData = track.spotify_data;
// Parse spotify_data if it's a string
if (typeof spotifyData === 'string') {
try {
spotifyData = JSON.parse(spotifyData);
} catch (e) {
continue;
}
}
// Extract cover URL
coverUrl = spotifyData?.album?.images?.[0]?.url;
// Add to list if unique and valid
if (coverUrl && !uniqueCovers.has(coverUrl)) {
uniqueCovers.add(coverUrl);
covers.push(coverUrl);
}
}
return covers;
}
/**
* Shuffle array using Fisher-Yates algorithm
*/
function shuffleArray(array) {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
/**
* Generate mosaic grid background HTML with continuous scrolling rows
*/
function generateMosaicBackground(coverUrls) {
// If less than 3 covers, use gradient fallback
if (!coverUrls || coverUrls.length < 3) {
return `
`;
}
// Cap covers per row to 15 for GPU performance (avoids hundreds of tiles)
if (coverUrls.length > 15) {
coverUrls = coverUrls.slice(0, 15);
}
const rows = 4;
let mosaicHTML = '';
// Calculate scroll speed based on number of images
// More images = longer duration to maintain consistent visual speed
// Minimum 40s to prevent scrolling too fast
const scrollSpeed = Math.max(40, coverUrls.length * 2);
for (let row = 0; row < rows; row++) {
const isEvenRow = row % 2 === 0;
const direction = isEvenRow ? 'left' : 'right';
// Randomize order for each row
const shuffledCovers = shuffleArray(coverUrls);
// Create row wrapper
mosaicHTML += `
`;
mosaicHTML += `
'; // Close row
mosaicHTML += '
'; // Close wrapper
}
mosaicHTML += '
';
mosaicHTML += '
'; // Dark overlay for readability
return mosaicHTML;
}
/**
* Open wishlist overview modal showing category breakdown
* This is the NEW entry point for wishlist from dashboard
*/
async function openWishlistOverviewModal() {
try {
showLoadingOverlay('Loading wishlist...');
// Fetch wishlist stats
const statsResponse = await fetch('/api/wishlist/stats');
const statsData = await statsResponse.json();
if (!statsResponse.ok) {
throw new Error(statsData.error || 'Failed to fetch wishlist stats');
}
const { singles, albums, total } = statsData;
if (total === 0) {
hideLoadingOverlay();
showToast('Wishlist is empty. No tracks to process.', 'info');
return;
}
// Fetch album covers for mosaic backgrounds
// Limit to 50 tracks per category (enough to get 20 unique covers while being efficient)
const albumCoversPromise = fetch('/api/wishlist/tracks?category=albums&limit=50').then(r => r.json());
const singleCoversPromise = fetch('/api/wishlist/tracks?category=singles&limit=50').then(r => r.json());
const [albumTracksData, singleTracksData] = await Promise.all([albumCoversPromise, singleCoversPromise]);
// Extract unique album covers (max 20 per category)
const albumCovers = extractUniqueCoverImages(albumTracksData.tracks || [], 20);
const singleCovers = extractUniqueCoverImages(singleTracksData.tracks || [], 20);
// Create modal if it doesn't exist
let modal = document.getElementById('wishlist-overview-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'wishlist-overview-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
}
// Fetch current cycle
const cycleResponse = await fetch('/api/wishlist/cycle');
const cycleData = await cycleResponse.json();
const currentCycle = cycleData.cycle || 'albums';
// Format countdown timer
const nextRunSeconds = statsData.next_run_in_seconds || 0;
const countdownText = formatCountdownTime(nextRunSeconds);
const nextCycleText = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles';
modal.innerHTML = `
${generateMosaicBackground(albumCovers)}
πΏ
Albums / EPs
${albums} tracks
${currentCycle === 'albums' ? '
Next in Queue
' : ''}
${generateMosaicBackground(singleCovers)}
π΅
Singles
${singles} tracks
${currentCycle === 'singles' ? '
Next in Queue
' : ''}
0 selected
Remove Selected
`;
modal.style.display = 'flex';
hideLoadingOverlay();
// Start countdown timer update interval
startWishlistCountdownTimer(currentCycle, nextRunSeconds);
} catch (error) {
console.error('Error opening wishlist overview:', error);
showToast(`Failed to load wishlist: ${error.message}`, 'error');
hideLoadingOverlay();
}
}
function startWishlistCountdownTimer(currentCycle, initialSeconds) {
// Clear any existing interval
if (wishlistCountdownInterval) {
clearInterval(wishlistCountdownInterval);
}
let remainingSeconds = initialSeconds;
const nextCycleText = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles';
wishlistCountdownInterval = setInterval(async () => {
remainingSeconds--;
// Check if auto-processing has started (every 2 seconds to avoid overwhelming backend)
if (remainingSeconds % 2 === 0 || remainingSeconds <= 0) {
// Use WebSocket data if available, otherwise fall back to HTTP
if (socketConnected && _lastWishlistStats) {
const data = _lastWishlistStats;
if (data.is_auto_processing) {
if (!_wishlistAutoProcessingNotified) {
navigateToPage('active-downloads');
showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info');
_wishlistAutoProcessingNotified = true;
}
return;
}
if (remainingSeconds <= 0) {
remainingSeconds = data.next_run_in_seconds || 0;
const timerElement = document.getElementById('wishlist-next-auto-timer');
if (timerElement) {
const countdownText = formatCountdownTime(remainingSeconds);
timerElement.textContent = `Next Auto: ${nextCycleText}${countdownText ? ' in ' + countdownText : ''}`;
}
}
} else {
try {
const response = await fetch('/api/wishlist/stats');
const data = await response.json();
// AUTO-CLOSE DETECTION: If auto-processing started, close modal and notify user (once)
if (data.is_auto_processing) {
if (!_wishlistAutoProcessingNotified) {
console.log('π€ [Wishlist] Auto-processing detected, closing overview modal');
closeWishlistOverviewModal();
showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info');
_wishlistAutoProcessingNotified = true;
}
return; // Exit interval
}
// Update remaining seconds if timer expired
if (remainingSeconds <= 0) {
remainingSeconds = data.next_run_in_seconds || 0;
// Also update cycle in case it changed
const newCycle = data.current_cycle || 'albums';
const newCycleText = newCycle === 'albums' ? 'Albums/EPs' : 'Singles';
const timerElement = document.getElementById('wishlist-next-auto-timer');
if (timerElement) {
const countdownText = formatCountdownTime(remainingSeconds);
timerElement.textContent = `Next Auto: ${newCycleText}${countdownText ? ' in ' + countdownText : ''}`;
}
}
} catch (error) {
console.debug('Error updating wishlist countdown:', error);
}
} // end else (HTTP fallback)
}
// Always update the display countdown
const timerElement = document.getElementById('wishlist-next-auto-timer');
if (timerElement) {
const countdownText = formatCountdownTime(remainingSeconds);
timerElement.textContent = `Next Auto: ${nextCycleText}${countdownText ? ' in ' + countdownText : ''}`;
}
}, 1000); // Update every second
}
function closeWishlistOverviewModal() {
console.log('πͺ closeWishlistOverviewModal() called');
// Stop countdown timer
if (wishlistCountdownInterval) {
clearInterval(wishlistCountdownInterval);
wishlistCountdownInterval = null;
}
const modal = document.getElementById('wishlist-overview-modal');
console.log('Modal element:', modal);
if (modal) {
modal.style.display = 'none';
console.log('Modal display set to none');
// Also remove from DOM to ensure clean state
modal.remove();
console.log('Modal removed from DOM');
} else {
console.warn('Modal element not found');
}
window.selectedWishlistCategory = null;
console.log('β
Modal closed');
}
// ββ #874: Wishlist ignore-list ("Ignored") modal ββββββββββββββββββββββββ
// Tracks the user removed from the wishlist or cancelled mid-download are
// auto-skipped (not re-queued) until they expire. This modal lets the user
// see what's currently ignored and lift the skip (un-ignore / clear all).
async function openWishlistIgnoreModal() {
let modal = document.getElementById('wishlist-ignore-modal');
if (modal) modal.remove();
modal = document.createElement('div');
modal.id = 'wishlist-ignore-modal';
modal.className = 'modal-overlay';
modal.style.cssText = 'display:flex;position:fixed;inset:0;z-index:10050;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);';
modal.innerHTML = `
Removed or cancelled tracks are skipped by auto-download until they expire. Un-ignore to allow auto-download again (you can always download manually).
`;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeWishlistIgnoreModal(); });
await loadWishlistIgnoreList();
}
function closeWishlistIgnoreModal() {
const modal = document.getElementById('wishlist-ignore-modal');
if (modal) modal.remove();
}
async function loadWishlistIgnoreList() {
const list = document.getElementById('wishlist-ignore-list');
const clearBtn = document.getElementById('wishlist-ignore-clear-btn');
if (!list) return;
try {
const resp = await fetch('/api/wishlist/ignore-list');
const data = await resp.json();
const entries = (data && data.entries) || [];
if (clearBtn) clearBtn.style.display = entries.length ? '' : 'none';
if (!entries.length) {
list.innerHTML = 'π Nothing ignored.
';
return;
}
const ttl = (data && data.ttl_days) || 30;
list.innerHTML = entries.map(e => {
const title = escapeHtml(e.track_name || e.track_id || 'Unknown');
const artist = escapeHtml(e.artist_name || '');
const reason = e.reason === 'cancelled' ? 'Cancelled' : 'Removed';
const tid = escapeHtml(String(e.track_id || ''));
return `
${title}
${artist}${artist ? ' Β· ' : ''}${reason} Β· skips ${ttl}d
Un-ignore
`;
}).join('');
} catch (err) {
list.innerHTML = 'Error loading ignored tracks
';
}
}
async function unignoreWishlistTrack(trackId) {
if (!trackId) return;
try {
const resp = await fetch('/api/wishlist/ignore-list/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_id: trackId }),
});
const data = await resp.json();
if (data && data.success) {
showToast('Track un-ignored β it can be auto-downloaded again.', 'success');
await loadWishlistIgnoreList();
} else {
showToast(`Un-ignore failed: ${(data && data.error) || 'unknown'}`, 'error');
}
} catch (err) {
showToast(`Un-ignore failed: ${err.message}`, 'error');
}
}
async function clearWishlistIgnoreList() {
if (!await showConfirmDialog({
title: 'Clear Ignored List',
message: 'Allow all currently-ignored tracks to be auto-downloaded again?',
confirmText: 'Clear All',
cancelText: 'Cancel',
})) return;
try {
const resp = await fetch('/api/wishlist/ignore-list/clear', { method: 'POST' });
const data = await resp.json();
if (data && data.success) {
showToast(`Cleared ${data.cleared || 0} ignored track(s).`, 'success');
await loadWishlistIgnoreList();
} else {
showToast(`Clear failed: ${(data && data.error) || 'unknown'}`, 'error');
}
} catch (err) {
showToast(`Clear failed: ${err.message}`, 'error');
}
}
async function cleanupWishlistOverview() {
console.log('π§Ή cleanupWishlistOverview() called');
if (!await showConfirmDialog({ title: 'Cleanup Wishlist', message: 'This will remove all tracks from the wishlist that already exist in your library. Continue?' })) {
return;
}
try {
showLoadingOverlay('Cleaning up wishlist...');
const response = await fetch('/api/wishlist/cleanup', {
method: 'POST'
});
const result = await response.json();
if (result.success) {
const removedCount = result.removed_count || 0;
if (removedCount > 0) {
showToast(`Cleanup complete! Removed ${removedCount} tracks that already exist in your library`, 'success');
} else {
showToast('No tracks needed to be removed', 'info');
}
// Check if wishlist is now empty
const statsResponse = await fetch('/api/wishlist/stats');
const statsData = await statsResponse.json();
if (statsData.total === 0) {
// Wishlist is empty, refresh the page to show empty state
wishlistPageState.isInitialized = false;
await initializeWishlistPage();
await updateWishlistCount();
} else {
// Wishlist still has items, refresh the page to show updated counts
wishlistPageState.isInitialized = false;
await initializeWishlistPage();
}
} else {
showToast(`Failed to cleanup wishlist: ${result.error || 'Unknown error'}`, 'error');
}
hideLoadingOverlay();
} catch (error) {
console.error('Error cleaning up wishlist:', error);
showToast(`Failed to cleanup wishlist: ${error.message}`, 'error');
hideLoadingOverlay();
}
}
async function clearEntireWishlist() {
console.log('ποΈ clearEntireWishlist() called');
if (!await showConfirmDialog({ title: 'Clear Wishlist', message: 'WARNING: This will permanently delete ALL tracks from your wishlist.\n\nThis action cannot be undone.\n\nAre you sure you want to continue?', confirmText: 'Clear All', destructive: true })) {
console.log('User cancelled confirmation');
return;
}
console.log('User confirmed, proceeding with clear...');
try {
showLoadingOverlay('Clearing wishlist...');
console.log('Loading overlay shown');
const response = await fetch('/api/wishlist/clear', {
method: 'POST'
});
console.log('API response received:', response.status);
const result = await response.json();
console.log('Clear wishlist response:', result);
hideLoadingOverlay();
console.log('Loading overlay hidden');
if (result.success) {
console.log('Clear was successful, showing toast...');
showToast('Wishlist cleared successfully', 'success');
console.log('Updating wishlist button count...');
await updateWishlistCount();
console.log('Refreshing wishlist page...');
wishlistPageState.isInitialized = false;
await initializeWishlistPage();
} else {
console.error('Clear failed:', result.error);
showToast(`Failed to clear wishlist: ${result.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Error clearing wishlist:', error);
hideLoadingOverlay();
showToast(`Failed to clear wishlist: ${error.message}`, 'error');
}
}
async function selectWishlistCategory(category) {
try {
window.selectedWishlistCategory = category;
const tracksList = document.getElementById('wishlist-tracks-list');
const categoryTracksSection = document.getElementById('wishlist-category-tracks');
const nebulaEl = document.getElementById('wishlist-nebula');
const downloadBtn = document.getElementById('wishlist-download-btn');
const categoryName = document.getElementById('wishlist-category-name');
if (nebulaEl) nebulaEl.style.display = 'none';
categoryTracksSection.style.display = 'block';
downloadBtn.style.display = 'inline-block';
categoryName.textContent = category === 'albums' ? 'Albums / EPs' : 'Singles';
tracksList.innerHTML = 'Loading tracks...
';
const _wlPageSize = window._wlNextLimit || 200;
window._wlNextLimit = null;
const response = await fetch(`/api/wishlist/tracks?category=${category}&limit=${_wlPageSize}`);
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to fetch tracks');
const tracks = data.tracks || [];
const totalAvailable = data.total || tracks.length;
window._wlCategory = category;
window._wlOffset = tracks.length;
window._wlTotal = totalAvailable;
if (tracks.length === 0) {
tracksList.innerHTML = 'No tracks in this category
';
return;
}
// For Albums/EPs, group by album
if (category === 'albums') {
const albumGroups = {};
tracks.forEach(track => {
let spotifyData = track.spotify_data;
if (typeof spotifyData === 'string') {
try {
spotifyData = JSON.parse(spotifyData);
} catch (e) {
spotifyData = null;
}
}
const rawAlbum = spotifyData?.album;
const albumName = (typeof rawAlbum === 'string' ? rawAlbum : rawAlbum?.name) || 'Unknown Album';
// Handle both object format {name: '...'} and sanitized string format
let artistName = 'Unknown Artist';
let artistId = null;
if (spotifyData?.artists?.[0]?.name) {
// Object format from Spotify API
artistName = spotifyData.artists[0].name;
artistId = spotifyData.artists[0].id;
} else if (spotifyData?.artists?.[0] && typeof spotifyData.artists[0] === 'string') {
// Sanitized string format
artistName = spotifyData.artists[0];
} else if (Array.isArray(track.artists) && track.artists.length > 0) {
// Fallback to track.artists
if (typeof track.artists[0] === 'string') {
artistName = track.artists[0];
} else if (track.artists[0]?.name) {
artistName = track.artists[0].name;
artistId = track.artists[0].id;
}
}
const albumImage = spotifyData?.album?.images?.[0]?.url || '';
// Use album ID if available, otherwise create unique key from album + artist
// Sanitize the ID to remove all special characters that could break DOM IDs or CSS selectors
const albumId = spotifyData?.album?.id || `${albumName}_${artistName}`
.replace(/[^a-zA-Z0-9\s_-]/g, '') // Remove all special chars except spaces, underscores, hyphens
.replace(/\s+/g, '_') // Replace spaces with underscores
.toLowerCase();
if (!albumGroups[albumId]) {
albumGroups[albumId] = {
albumName,
artistName,
artistId,
albumImage,
tracks: []
};
}
const spotifyTrackId = track.spotify_track_id || track.id || '';
albumGroups[albumId].tracks.push({
name: track.name || 'Unknown Track',
artistName,
trackNumber: spotifyData?.track_number || 0,
spotifyTrackId
});
});
// Render album cards
let albumsHTML = '';
Object.entries(albumGroups).forEach(([albumId, albumData]) => {
// Sort tracks by track number
albumData.tracks.sort((a, b) => a.trackNumber - b.trackNumber);
const tracksListHTML = albumData.tracks.map(track => {
const safeTrackId = escapeHtml(track.spotifyTrackId || '');
const safeTrackName = escapeHtml(track.name || 'Unknown Track');
return `
${safeTrackName}
ποΈ
`;
}).join('');
// Handle missing album images with a placeholder
const safeAlbumId = escapeHtml(albumId);
const safeAlbumName = escapeHtml(albumData.albumName || 'Unknown Album');
const safeArtistName = escapeHtml(albumData.artistName || 'Unknown Artist');
const safeAlbumImage = escapeHtml(albumData.albumImage || '').replace(/'/g, ''');
const albumImageStyle = albumData.albumImage
? `background-image: url('${safeAlbumImage}')`
: `background: linear-gradient(135deg, rgba(30, 30, 30, 0.9) 0%, rgba(50, 50, 50, 0.9) 100%); display: flex; align-items: center; justify-content: center; font-size: 40px;`;
const albumImageContent = albumData.albumImage ? '' : '
πΏ ';
albumsHTML += `
`;
});
albumsHTML += '
';
tracksList.innerHTML = albumsHTML;
if (totalAvailable > tracks.length) {
tracksList.insertAdjacentHTML('beforeend',
`Load More (${tracks.length} of ${totalAvailable}) `);
}
_attachWishlistDelegation(tracksList);
} else {
// For Singles, show list with album images
let tracksHTML = '';
tracks.forEach((track, index) => {
const trackName = track.name || 'Unknown Track';
let spotifyData = track.spotify_data;
if (typeof spotifyData === 'string') {
try {
spotifyData = JSON.parse(spotifyData);
} catch (e) {
spotifyData = null;
}
}
let artistName = 'Unknown Artist';
if (spotifyData?.artists?.[0]?.name) {
artistName = spotifyData.artists[0].name;
} else if (Array.isArray(track.artists) && track.artists.length > 0) {
if (typeof track.artists[0] === 'string') {
artistName = track.artists[0];
} else if (track.artists[0]?.name) {
artistName = track.artists[0].name;
}
}
let albumName = 'Unknown Album';
if (spotifyData?.album?.name) {
albumName = spotifyData.album.name;
} else if (typeof track.album === 'string') {
albumName = track.album;
} else if (track.album?.name) {
albumName = track.album.name;
}
const albumImage = spotifyData?.album?.images?.[0]?.url || '';
const spotifyTrackId = track.spotify_track_id || track.id || '';
const safeTrackId = escapeHtml(spotifyTrackId);
const safeTrackName = escapeHtml(trackName);
const safeArtistName = escapeHtml(artistName);
const safeAlbumName = escapeHtml(albumName);
const safeAlbumImage = escapeHtml(albumImage).replace(/'/g, ''');
tracksHTML += `
${safeTrackName}
${safeArtistName} β’ ${safeAlbumName}
ποΈ
`;
});
tracksList.innerHTML = tracksHTML;
if (totalAvailable > tracks.length) {
tracksList.insertAdjacentHTML('beforeend',
`Load More (${tracks.length} of ${totalAvailable}) `);
}
_attachWishlistDelegation(tracksList);
}
} catch (error) {
console.error('Error loading category tracks:', error);
showToast(`Failed to load tracks: ${error.message}`, 'error');
}
}
async function loadMoreWishlistTracks() {
const btn = document.querySelector('.wishlist-load-more-btn');
if (btn) { btn.textContent = 'Loading...'; btn.disabled = true; }
// Increase page size and reload
window._wlOffset = (window._wlOffset || 200) + 200;
// Override the page size for this reload
window._wlNextLimit = window._wlOffset;
selectWishlistCategory(window._wlCategory);
}
function _attachWishlistDelegation(container) {
// Single click handler for all wishlist album/track interactions
container.addEventListener('click', (e) => {
const target = e.target;
// Skip checkbox wrapper clicks β handled by change listener
if (target.closest('.wishlist-checkbox-wrapper')) return;
// Album header click (expand/collapse)
const header = target.closest('.wishlist-album-header');
if (header && !target.closest('.wishlist-delete-album-btn')) {
toggleAlbumTracks(header.dataset.albumId);
return;
}
// Album delete button
const albumDelBtn = target.closest('.wishlist-delete-album-btn');
if (albumDelBtn) {
e.stopPropagation();
removeAlbumFromWishlist(albumDelBtn.dataset.albumId, e);
return;
}
// Track delete button
const trackDelBtn = target.closest('.wishlist-delete-btn');
if (trackDelBtn && trackDelBtn.dataset.trackId) {
e.stopPropagation();
removeTrackFromWishlist(trackDelBtn.dataset.trackId, e);
return;
}
});
// Separate change handler for checkboxes (more reliable than click for inputs)
container.addEventListener('change', (e) => {
const target = e.target;
if (target.classList.contains('wishlist-album-select-all-cb')) {
toggleWishlistAlbumSelection(target.dataset.albumId, target.checked);
} else if (target.classList.contains('wishlist-select-cb')) {
updateWishlistBatchBar();
}
});
}
function backToCategories() {
_nebulaBack();
}
function toggleAlbumTracks(albumId) {
const tracksElement = document.getElementById(`tracks-${albumId}`);
const expandIcon = document.getElementById(`expand-icon-${albumId}`);
if (tracksElement.style.display === 'none') {
tracksElement.style.display = 'block';
expandIcon.textContent = 'β²';
} else {
tracksElement.style.display = 'none';
expandIcon.textContent = 'βΌ';
}
}
/**
* Get all checked wishlist track checkboxes
*/
function getCheckedWishlistTracks() {
return Array.from(document.querySelectorAll('.wishlist-select-cb:checked'));
}
/**
* Toggle select all / deselect all tracks in the current wishlist category
*/
function toggleWishlistSelectAll() {
const allCheckboxes = document.querySelectorAll('.wishlist-select-cb');
const albumCheckboxes = document.querySelectorAll('.wishlist-album-select-all-cb');
const btn = document.getElementById('wishlist-select-all-btn');
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
const newState = !allChecked;
allCheckboxes.forEach(cb => { cb.checked = newState; });
albumCheckboxes.forEach(cb => { cb.checked = newState; });
// Expand all albums when selecting all
if (newState) {
document.querySelectorAll('.wishlist-album-tracks').forEach(el => {
el.style.display = 'block';
});
document.querySelectorAll('[id^="expand-icon-"]').forEach(icon => {
icon.textContent = 'β²';
});
}
if (btn) btn.textContent = newState ? 'Deselect All' : 'Select All';
updateWishlistBatchBar();
}
/**
* Update the wishlist batch action bar based on checkbox selection
*/
function updateWishlistBatchBar() {
const checked = getCheckedWishlistTracks();
const bar = document.getElementById('wishlist-batch-bar');
const countEl = document.getElementById('wishlist-batch-count');
if (!bar || !countEl) return;
if (checked.length > 0) {
bar.style.display = 'flex';
countEl.textContent = `${checked.length} selected`;
} else {
bar.style.display = 'none';
}
// Sync the Select All button text
const btn = document.getElementById('wishlist-select-all-btn');
if (btn) {
const allCheckboxes = document.querySelectorAll('.wishlist-select-cb');
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
btn.textContent = allChecked ? 'Deselect All' : 'Select All';
}
}
/**
* Toggle all track checkboxes within an album when album header checkbox is clicked
*/
function toggleWishlistAlbumSelection(albumId, checked) {
const tracksContainer = document.getElementById(`tracks-${albumId}`);
if (tracksContainer) {
// Expand the album tracks if selecting
if (checked) {
tracksContainer.style.display = 'block';
const expandIcon = document.getElementById(`expand-icon-${albumId}`);
if (expandIcon) expandIcon.textContent = 'β²';
}
tracksContainer.querySelectorAll('.wishlist-select-cb').forEach(cb => {
cb.checked = checked;
});
}
updateWishlistBatchBar();
}
/**
* Batch remove selected tracks from wishlist
*/
async function batchRemoveFromWishlist() {
const checked = getCheckedWishlistTracks();
if (checked.length === 0) return;
const count = checked.length;
const confirmed = await showConfirmationModal(
'Remove Tracks',
`Remove ${count} track${count !== 1 ? 's' : ''} from your wishlist?`,
'ποΈ'
);
if (!confirmed) return;
const trackIds = checked.map(cb => cb.getAttribute('data-track-id'));
try {
const response = await fetch('/api/wishlist/remove-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spotify_track_ids: trackIds })
});
const data = await response.json();
if (data.success) {
showToast(`Removed ${data.removed} track(s) from wishlist`, 'success');
// Reload the current category to refresh the list
if (window.selectedWishlistCategory) {
await selectWishlistCategory(window.selectedWishlistCategory);
}
// Update wishlist count in sidebar
await updateWishlistCount();
} else {
showToast(`Failed to remove tracks: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error batch removing from wishlist:', error);
showToast('Failed to remove tracks from wishlist', 'error');
}
}
function showConfirmationModal(title, message, icon = 'β οΈ') {
return new Promise((resolve) => {
// Create modal if it doesn't exist
let modal = document.getElementById('confirmation-modal-overlay');
if (!modal) {
modal = document.createElement('div');
modal.id = 'confirmation-modal-overlay';
modal.className = 'confirmation-modal-overlay';
document.body.appendChild(modal);
}
// Set modal content
modal.innerHTML = `
${icon}
${title}
${message}
Cancel
Yes, Remove
`;
// Show modal with animation
setTimeout(() => {
modal.classList.add('show');
}, 10);
// Escape key handler - defined outside so we can remove it
const handleEscape = (e) => {
if (e.key === 'Escape') {
handleCancel();
}
};
// Handle button clicks
const handleCancel = () => {
document.removeEventListener('keydown', handleEscape);
modal.classList.remove('show');
setTimeout(() => {
modal.remove();
}, 200);
resolve(false);
};
const handleConfirm = () => {
document.removeEventListener('keydown', handleEscape);
modal.classList.remove('show');
setTimeout(() => {
modal.remove();
}, 200);
resolve(true);
};
document.getElementById('confirm-cancel').addEventListener('click', handleCancel);
document.getElementById('confirm-yes').addEventListener('click', handleConfirm);
// Close on overlay click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
handleCancel();
}
});
// Add Escape key listener
document.addEventListener('keydown', handleEscape);
});
}
async function removeTrackFromWishlist(spotifyTrackId, event) {
// Stop event propagation to prevent triggering parent click handlers
if (event) {
event.stopPropagation();
}
const confirmed = await showConfirmationModal(
'Remove Track',
'Are you sure you want to remove this track from your wishlist?',
'ποΈ'
);
if (!confirmed) {
return;
}
try {
const response = await fetch('/api/wishlist/remove-track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spotify_track_id: spotifyTrackId })
});
const data = await response.json();
if (data.success) {
showToast('Track removed from wishlist', 'success');
// Reload the current category to refresh the list
if (window.selectedWishlistCategory) {
await selectWishlistCategory(window.selectedWishlistCategory);
}
// Update wishlist count in sidebar
await updateWishlistCount();
} else {
showToast(`Failed to remove track: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error removing track from wishlist:', error);
showToast('Failed to remove track from wishlist', 'error');
}
}
async function removeAlbumFromWishlist(albumId, event) {
// Stop event propagation to prevent triggering parent click handlers
if (event) {
event.stopPropagation();
}
const confirmed = await showConfirmationModal(
'Remove Album',
'Are you sure you want to remove all tracks from this album from your wishlist?',
'πΏ'
);
if (!confirmed) {
return;
}
try {
const response = await fetch('/api/wishlist/remove-album', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId })
});
const data = await response.json();
if (data.success) {
showToast(`Removed ${data.removed_count} track(s) from wishlist`, 'success');
// Reload the current category to refresh the list
if (window.selectedWishlistCategory) {
await selectWishlistCategory(window.selectedWishlistCategory);
}
// Update wishlist count in sidebar
await updateWishlistCount();
} else {
showToast(`Failed to remove album: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error removing album from wishlist:', error);
showToast('Failed to remove album from wishlist', 'error');
}
}
async function downloadSelectedCategory() {
const category = window.selectedWishlistCategory;
if (!category) {
showToast('No category selected', 'error');
return;
}
// Collect checked track IDs
const checkedBoxes = document.querySelectorAll('.wishlist-select-cb:checked');
const selectedTrackIds = new Set(Array.from(checkedBoxes).map(cb => cb.dataset.trackId).filter(Boolean));
await openDownloadMissingWishlistModal(category, selectedTrackIds.size > 0 ? selectedTrackIds : null);
}
async function openDownloadMissingWishlistModal(category = null, selectedTrackIds = null) {
showLoadingOverlay('Loading wishlist...');
const playlistId = "wishlist"; // Use a consistent ID for wishlist
// Check if a process is already active for the wishlist
if (activeDownloadProcesses[playlistId]) {
console.log(`Modal for wishlist already exists. Showing it.`);
const process = activeDownloadProcesses[playlistId];
if (process.modalElement) {
// Show helpful message if it's a completed process
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
WishlistModalState.setVisible(); // Track that modal is now visible
}
hideLoadingOverlay(); // Always hide overlay before returning
return; // Don't create a new one
}
console.log(`π₯ Opening Download Missing Tracks modal for wishlist${category ? ' (' + category + ')' : ''}`);
// Store category in global state for when process starts
window.currentWishlistCategory = category;
// Fetch actual wishlist tracks from the server
let tracks;
try {
// Build API URL with optional category filter
const apiUrl = category ? `/api/wishlist/tracks?category=${category}` : '/api/wishlist/tracks';
const response = await fetch('/api/wishlist/count');
const countData = await response.json();
if (countData.count === 0) {
showToast('Wishlist is empty. No tracks to download.', 'info');
hideLoadingOverlay();
return;
}
// Fetch the actual wishlist tracks for display (filtered by category if specified)
const tracksResponse = await fetch(apiUrl);
if (!tracksResponse.ok) {
throw new Error('Failed to fetch wishlist tracks');
}
const tracksData = await tracksResponse.json();
tracks = tracksData.tracks || [];
// Filter to only selected tracks if user made a selection
if (selectedTrackIds && selectedTrackIds.size > 0) {
tracks = tracks.filter(t => selectedTrackIds.has(t.id) || selectedTrackIds.has(t.spotify_track_id));
console.log(`π₯ Filtered to ${tracks.length} selected tracks (from ${tracksData.tracks?.length || 0} total)`);
}
} catch (error) {
showToast(`Failed to fetch wishlist data: ${error.message}`, 'error');
hideLoadingOverlay();
return;
}
currentPlaylistTracks = tracks;
currentModalPlaylistId = playlistId;
let modal = document.createElement('div');
modal.id = `download-missing-modal-${playlistId}`; // Unique ID
modal.className = 'download-missing-modal'; // Use class for styling
modal.style.display = 'none'; // Start hidden
document.body.appendChild(modal);
// Register the new process in our global state tracker
activeDownloadProcesses[playlistId] = {
status: 'idle', // idle, running, complete, cancelled
modalElement: modal,
poller: null,
batchId: null,
playlist: { id: playlistId, name: "Wishlist" }, // Create a pseudo-playlist object
tracks: tracks
};
// Generate hero section for wishlist context
const heroContext = {
type: 'wishlist',
trackCount: tracks.length,
playlistId: playlistId
};
modal.innerHTML = `
π Library Analysis
Ready to start
β¬ Downloads
Waiting for analysis
#
Track
Artist
Library Match
Download Status
Actions
${tracks.map((track, index) => `
${index + 1}
${renderModalTrackPlayButton(playlistId, index)}${escapeHtml(track.name)}
${escapeHtml(formatArtists(track.artists))}
π Pending
-
-
`).join('')}
`;
applyProgressiveTrackRendering(playlistId, tracks.length);
modal.style.display = 'flex';
hideLoadingOverlay();
WishlistModalState.setVisible(); // Track that new wishlist modal is now visible
}
async function startWishlistMissingTracksProcess(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
console.log(`π Kicking off wishlist missing tracks process`);
try {
process.status = 'running';
// Note: Wishlist processes don't affect sync page refresh button state
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none';
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block';
// Check if force download toggle is enabled
const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`);
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
// Hide the force download toggle during processing
const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null;
if (forceToggleContainer) {
forceToggleContainer.style.display = 'none';
}
// Extract track IDs from what the user is currently seeing in the modal
// This prevents race conditions where wishlist changes between modal open and analysis start
const trackIds = process.tracks ? process.tracks.map(t => t.spotify_track_id || t.id).filter(id => id) : null;
console.log(`π― [Wishlist] Sending ${trackIds ? trackIds.length : 'all'} specific track IDs to prevent race condition`);
const response = await fetch('/api/wishlist/download_missing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
force_download_all: forceDownloadAll,
category: window.currentWishlistCategory, // Keep for backward compat
track_ids: trackIds // NEW: Send exact tracks to process
})
});
const data = await response.json();
if (!data.success) {
// Special handling for auto-processing conflict
if (response.status === 409) {
console.log('π€ [Wishlist] Auto-processing is running, redirecting to download manager');
showToast('Wishlist auto-processing is already running. Opening Download Manager...', 'info');
// Close wishlist modal and show download manager
const wishlistModal = document.getElementById('download-modal-wishlist');
if (wishlistModal) {
wishlistModal.remove();
}
delete activeDownloadProcesses[playlistId];
// Open download manager to show active batch
setTimeout(() => {
const downloadManager = document.getElementById('download-manager-modal');
if (downloadManager) {
downloadManager.style.display = 'flex';
} else {
openDownloadManagerModal();
}
}, 300);
return;
}
// Special handling for rate limit
if (response.status === 429) {
throw new Error(`${data.error} Try closing some other download processes first.`);
}
throw new Error(data.error);
}
process.batchId = data.batch_id;
console.log(`β
Wishlist process started successfully. Batch ID: ${data.batch_id}`);
// Start polling for updates
startModalDownloadPolling(playlistId);
} catch (error) {
console.error('Error starting wishlist missing tracks process:', error);
showToast(`Error: ${error.message}`, 'error');
// Reset UI state on error
process.status = 'idle';
// Note: Wishlist processes don't affect sync page refresh button state
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'inline-block';
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none';
// Show the force download toggle again
const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container');
if (forceToggleContainer) {
forceToggleContainer.style.display = 'flex';
}
}
}
async function startMissingTracksProcess(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
console.log(`π Kicking off unified missing tracks process for playlist: ${playlistId}`);
try {
process.status = 'running';
updatePlaylistCardUI(playlistId);
updateRefreshButtonState();
// Set album to downloading status if this is an artist album
if (playlistId.startsWith('artist_album_')) {
// Format: artist_album_{artist.id}_{album.id}
const parts = playlistId.split('_');
if (parts.length >= 4) {
const albumId = parts.slice(3).join('_'); // In case album ID has underscores
const totalTracks = process.tracks ? process.tracks.length : 0;
setAlbumDownloadingStatus(albumId, 0, totalTracks);
console.log(`π Set album ${albumId} to downloading status (0/${totalTracks} tracks)`);
console.log(`π Virtual playlist ID: ${playlistId} β Album ID: ${albumId}`);
}
}
// Update YouTube playlist phase to 'downloading' if this is a YouTube playlist
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'downloading');
// Also update mirrored playlist card if applicable
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'downloading');
}
}
// Update Tidal playlist phase to 'downloading' if this is a Tidal playlist
if (playlistId.startsWith('tidal_')) {
const tidalPlaylistId = playlistId.replace('tidal_', '');
if (tidalPlaylistStates[tidalPlaylistId]) {
tidalPlaylistStates[tidalPlaylistId].phase = 'downloading';
updateTidalCardPhase(tidalPlaylistId, 'downloading');
console.log(`π Updated Tidal playlist ${tidalPlaylistId} to downloading phase`);
}
}
// Update Beatport chart phase to 'downloading' if this is a Beatport chart
if (playlistId.startsWith('beatport_')) {
const urlHash = playlistId.replace('beatport_', '');
const state = youtubePlaylistStates[urlHash];
if (state && state.is_beatport_playlist) {
const chartHash = state.beatport_chart_hash || urlHash;
// Update frontend states
state.phase = 'downloading';
if (beatportChartStates[chartHash]) {
beatportChartStates[chartHash].phase = 'downloading';
}
// Update card UI
updateBeatportCardPhase(chartHash, 'downloading');
// Update backend state
try {
fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'downloading' })
});
} catch (error) {
console.warn('β οΈ Error updating backend Beatport phase to downloading:', error);
}
console.log(`π Updated Beatport chart ${chartHash} to downloading phase`);
}
}
// Update Spotify Public playlist phase to 'downloading' if this is a Spotify Public playlist
if (playlistId.startsWith('spotify_public_')) {
const urlHash = playlistId.replace('spotify_public_', '');
if (spotifyPublicPlaylistStates[urlHash]) {
spotifyPublicPlaylistStates[urlHash].phase = 'downloading';
spotifyPublicPlaylistStates[urlHash].convertedSpotifyPlaylistId = playlistId;
updateSpotifyPublicCardPhase(urlHash, 'downloading');
try {
fetch(`/api/spotify-public/update_phase/${urlHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId })
});
} catch (error) {
console.warn('Error updating backend Spotify Public phase to downloading:', error);
}
console.log(`π Updated Spotify Public playlist ${urlHash} to downloading phase`);
}
}
// Update Deezer playlist phase to 'downloading' if this is a Deezer playlist
if (playlistId.startsWith('deezer_')) {
const deezerPlaylistId = playlistId.replace('deezer_', '');
if (deezerPlaylistStates[deezerPlaylistId]) {
deezerPlaylistStates[deezerPlaylistId].phase = 'downloading';
deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId = playlistId;
updateDeezerCardPhase(deezerPlaylistId, 'downloading');
try {
fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId })
});
} catch (error) {
console.warn('Error updating backend Deezer phase to downloading:', error);
}
console.log(`π Updated Deezer playlist ${deezerPlaylistId} to downloading phase`);
}
}
// Update ListenBrainz playlist phase to 'downloading' if this is a ListenBrainz playlist
if (playlistId.startsWith('listenbrainz_')) {
const playlistMbid = playlistId.replace('listenbrainz_', '');
const state = listenbrainzPlaylistStates[playlistMbid];
if (state) {
// Update frontend state
state.phase = 'downloading';
// Update backend state
try {
fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'downloading' })
});
} catch (error) {
console.warn('β οΈ Error updating backend ListenBrainz phase to downloading:', error);
}
console.log(`π Updated ListenBrainz playlist ${playlistMbid} to downloading phase`);
}
}
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none';
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block';
// Hide wishlist button if it exists (only for non-wishlist modals)
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`);
if (wishlistBtn) {
wishlistBtn.style.display = 'none';
}
// Add to discover download sidebar if this is a discover page download
if (process.discoverMetadata) {
const playlistName = process.playlist.name;
const imageUrl = process.discoverMetadata.imageUrl;
const type = process.discoverMetadata.type;
addDiscoverDownload(playlistId, playlistName, type, imageUrl);
console.log(`π₯ [BEGIN ANALYSIS] Added discover download: ${playlistName}`);
}
// Check if force download toggle is enabled
const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`);
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
// Issue #797 β per-request "Skip AcoustID verification" toggle. Absent
// checkbox (other call sites) β false, so behavior is unchanged there.
const skipAcoustidCheckbox = document.getElementById(`skip-acoustid-${playlistId}`);
const skipAcoustid = skipAcoustidCheckbox ? skipAcoustidCheckbox.checked : false;
// Check if playlist folder mode toggle is enabled (only for sync page playlists)
const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function'
? isPlaylistOrganizeEnabled(playlistId)
: (document.getElementById(`playlist-folder-mode-${playlistId}`)?.checked ?? false);
// Hide the force download toggle during processing
const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null;
if (forceToggleContainer) {
forceToggleContainer.style.display = 'none';
}
// Filter tracks based on checkbox selection (if checkboxes exist in this modal)
const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`);
let selectedTracks = process.tracks;
if (tbody) {
const allCbs = tbody.querySelectorAll('.track-select-cb');
if (allCbs.length > 0) {
// Checkboxes exist β filter to only checked tracks
const checkedCbs = tbody.querySelectorAll('.track-select-cb:checked');
const selectedIndices = new Set([...checkedCbs].map(cb => parseInt(cb.dataset.trackIndex)));
console.log(`π² [Track Selection] Total checkboxes: ${allCbs.length}, Checked: ${checkedCbs.length}`);
console.log(`π² [Track Selection] Checked indices:`, [...selectedIndices]);
console.log(`π² [Track Selection] process.tracks has ${process.tracks.length} items, first: "${process.tracks[0]?.name}", last: "${process.tracks[process.tracks.length - 1]?.name}"`);
// Stamp each selected track with its original table index so the backend
// maps status updates back to the correct modal row
selectedTracks = process.tracks
.map((track, i) => ({ ...track, _original_index: i }))
.filter(track => selectedIndices.has(track._original_index));
console.log(`π² [Track Selection] Filtered to ${selectedTracks.length} tracks:`, selectedTracks.map(t => `[${t._original_index}] ${t.name}`));
// Disable checkboxes once analysis starts
allCbs.forEach(cb => { cb.disabled = true; });
}
}
const selectAllCb = document.getElementById(`select-all-${playlistId}`);
if (selectAllCb) selectAllCb.disabled = true;
// Prepare request body - add album/artist context for artist album downloads
const wingItState = youtubePlaylistStates[playlistId] || {};
const isWingIt = wingItState.wing_it || false;
const requestBody = {
tracks: selectedTracks,
force_download_all: forceDownloadAll || isWingIt,
ignore_manual_matches: forceDownloadAll,
wing_it: isWingIt,
skip_acoustid: skipAcoustid,
};
// If this is an artist album download, use album name and include full context
// Match 'artist_album_', 'enhanced_search_album_', 'discover_album_', and 'seasonal_album_' prefixes
// Note: 'enhanced_search_track_' is excluded β single track search results use singles context
const _isAlbumContext = playlistId.startsWith('artist_album_') || playlistId.startsWith('enhanced_search_album_') || playlistId.startsWith('discover_album_') || playlistId.startsWith('seasonal_album_') || playlistId.startsWith('spotify_library_') || playlistId.startsWith('issue_download_') || playlistId.startsWith('library_redownload_') || playlistId.startsWith('beatport_release_');
const _isSearchTrack = playlistId.startsWith('enhanced_search_track_') || playlistId.startsWith('gsearch_track_');
if (_isAlbumContext || _isSearchTrack) {
requestBody.playlist_name = process.album?.name || process.playlist.name;
requestBody.is_album_download = _isAlbumContext; // false for single track search results
requestBody.album_context = process.album; // Full Spotify album object
requestBody.artist_context = process.artist; // Full Spotify artist object
console.log(`π΅ [${_isAlbumContext ? 'Album' : 'Single Track'}] Sending context: ${process.album?.name} by ${process.artist?.name}`);
} else {
// For playlists/wishlists, use the virtual playlist name
requestBody.playlist_name = process.playlist.name;
// Add playlist folder mode flag for sync page playlists
requestBody.playlist_folder_mode = playlistFolderMode;
if (playlistFolderMode) {
console.log(`π [Playlist Folder] Enabled for playlist: ${process.playlist.name}`);
}
}
let response = await fetch(`/api/playlists/${playlistId}/start-missing-process`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
let data = await response.json();
// Blocklist (Phase 2b): whole album/artist is blocked β confirm override.
if (data.blocked) {
if (!confirmBlockedDownload(data)) {
showToast(`Skipped β ${data.blocked_name} is blocklisted`, 'info');
return;
}
requestBody.ignore_blocklist = true;
response = await fetch(`/api/playlists/${playlistId}/start-missing-process`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
data = await response.json();
}
if (!data.success) {
// Special handling for rate limit
if (response.status === 429) {
throw new Error(`${data.error} Try closing some other download processes first.`);
}
throw new Error(data.error);
}
process.batchId = data.batch_id;
// Update Beatport backend state with download_process_id now that we have the batchId
if (playlistId.startsWith('beatport_')) {
const urlHash = playlistId.replace('beatport_', '');
const state = youtubePlaylistStates[urlHash];
if (state && state.is_beatport_playlist) {
const chartHash = state.beatport_chart_hash || urlHash;
try {
fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phase: 'downloading',
download_process_id: data.batch_id
})
});
console.log(`π Updated Beatport backend with download_process_id: ${data.batch_id}`);
} catch (error) {
console.warn('β οΈ Error updating Beatport backend with download_process_id:', error);
}
}
}
// Update ListenBrainz backend state with download_process_id and convertedSpotifyPlaylistId
if (playlistId.startsWith('listenbrainz_')) {
const playlistMbid = playlistId.replace('listenbrainz_', '');
const state = listenbrainzPlaylistStates[playlistMbid];
if (state) {
// Store in frontend state
state.download_process_id = data.batch_id;
state.convertedSpotifyPlaylistId = playlistId;
// Update backend state
try {
fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phase: 'downloading',
download_process_id: data.batch_id,
converted_spotify_playlist_id: playlistId
})
});
console.log(`π Updated ListenBrainz backend with download_process_id: ${data.batch_id}`);
} catch (error) {
console.warn('β οΈ Error updating ListenBrainz backend with download_process_id:', error);
}
}
}
startModalDownloadPolling(playlistId);
} catch (error) {
showToast(`Failed to start process: ${error.message}`, 'error');
process.status = 'cancelled';
// Reset button states on error
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`);
if (beginBtn) beginBtn.style.display = 'inline-block';
if (cancelBtn) cancelBtn.style.display = 'none';
if (wishlistBtn) wishlistBtn.style.display = 'inline-block';
// Show the force download toggle again
const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container');
if (forceToggleContainer) {
forceToggleContainer.style.display = 'flex';
}
cleanupDownloadProcess(playlistId);
}
}
function updateTrackAnalysisResults(playlistId, results) {
// Update match results for all rows (tracks are now pre-populated)
for (const result of results) {
const matchElement = document.getElementById(`match-${playlistId}-${result.track_index}`);
if (matchElement) {
matchElement.textContent = result.found ? 'β
Found' : 'β Missing';
matchElement.className = `track-match-status ${result.found ? 'match-found' : 'match-missing'}`;
}
}
}
function getModalTrackArtistName(track, fallbackArtist = '') {
const formatted = formatArtists(track?.artists);
if (formatted && formatted !== 'Unknown Artist') return formatted;
return track?.artist_name || track?.artist || fallbackArtist || formatted || '';
}
function getModalTrackAlbumTitle(track, process = null) {
if (track?.album) {
if (typeof track.album === 'string') return track.album;
if (track.album.name) return track.album.name;
if (track.album.title) return track.album.title;
}
if (process?.album) {
return process.album.name || process.album.title || '';
}
return '';
}
function renderModalTrackPlayButton(playlistId, trackIndex) {
return `▶ `;
}
async function playTrackFromLibraryOrStream(track, albumTitle = '', artistName = '') {
const title = track?.title || track?.name || '';
if (!title) {
showToast('No track title available to play', 'error');
return;
}
if (track?.file_path && typeof playLibraryTrack === 'function') {
await playLibraryTrack({
id: track.id || track.track_id || null,
title,
file_path: track.file_path,
_stats_image: track._stats_image || track.album_thumb_url || null,
bitrate: track.bitrate,
artist_id: track.artist_id,
album_id: track.album_id
}, albumTitle, artistName);
return;
}
try {
const res = await fetch('/api/stats/resolve-track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, artist: artistName })
});
const data = await res.json();
if (data.success && data.track && data.track.file_path && typeof playLibraryTrack === 'function') {
await playLibraryTrack({
...data.track,
title: data.track.title || title,
_stats_image: data.track.album_thumb_url || data.track.artist_thumb_url || null
}, data.track.album_title || albumTitle, data.track.artist_name || artistName);
return;
}
} catch (e) {
console.debug('Library resolve failed before stream fallback:', e);
}
if (typeof _gsPlayTrack === 'function') {
await _gsPlayTrack(title, artistName, albumTitle);
} else {
showToast('Playback is not available here', 'error');
}
}
async function playDownloadModalTrack(playlistId, trackIndex) {
const process = activeDownloadProcesses[playlistId];
const track = process?.tracks?.[trackIndex] || playlistTrackCache[playlistId]?.[trackIndex];
if (!track) {
showToast('Track is no longer available in this modal', 'error');
return;
}
await playTrackFromLibraryOrStream(
track,
getModalTrackAlbumTitle(track, process),
getModalTrackArtistName(track, process?.artist?.name || '')
);
}
// ============================================================================
// GLOBAL BATCHED POLLING SYSTEM - Optimized for multiple concurrent modals
// ============================================================================
let globalDownloadStatusPoller = null;
let globalPollingFailureCount = 0; // Track consecutive failures for exponential backoff
let globalPollingBaseInterval = 2000; // Base polling interval in ms - MATCHES sync.py exactly
function startGlobalDownloadPolling() {
// Always run HTTP polling as a fallback β WebSocket connections can silently
// stop delivering messages (room subscription lost, server emit error, proxy
// timeout) without triggering a disconnect event. The 2-second poll is cheap
// (single batched request) and ensures modals never go stale.
if (globalDownloadStatusPoller) {
console.debug('π [Global Polling] Already running, skipping start');
return; // Prevent duplicate pollers
}
console.log('π [Global Polling] Starting batched download status polling');
globalDownloadStatusPoller = setInterval(async () => {
if (document.hidden) return; // Skip polling when tab is not visible
// Get all active processes that need polling
const activeBatchIds = [];
const batchToPlaylistMap = {};
let hasOpenWishlistModal = false;
Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => {
// Include running AND recently-completed batches β ensures late task
// status updates still reach the modal so rows don't freeze mid-download
if (process.batchId && (process.status === 'running' || process.status === 'complete')) {
activeBatchIds.push(process.batchId);
batchToPlaylistMap[process.batchId] = playlistId;
}
// Check if there's an open wishlist modal (visible and idle/waiting)
if (playlistId === 'wishlist' && process.modalElement &&
process.modalElement.style.display === 'flex' &&
(!process.batchId || process.status !== 'running')) {
hasOpenWishlistModal = true;
}
});
// Special handling for open wishlist modal - check for new auto-processing
if (hasOpenWishlistModal) {
try {
const response = await fetch('/api/active-processes');
if (response.ok) {
const data = await response.json();
const processes = data.active_processes || [];
const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist');
if (serverWishlistProcess) {
console.log('π [Global Polling] Detected auto-processing for open wishlist modal - rehydrating');
await rehydrateModal(serverWishlistProcess, false); // false = not user-requested
}
}
} catch (error) {
console.debug('β οΈ [Global Polling] Failed to check for wishlist auto-processing:', error);
}
}
if (activeBatchIds.length === 0) {
console.debug('π [Global Polling] No active processes, continuing polling');
return;
}
try {
// Single batched API call for all active processes
const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&');
const response = await fetch(`/api/download_status/batch?${queryParams}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.debug(`π [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`);
// Process each batch's status data using existing logic
Object.entries(data.batches).forEach(([batchId, statusData]) => {
const playlistId = batchToPlaylistMap[batchId];
if (!playlistId || statusData.error) {
if (statusData.error) {
console.error(`β [Global Polling] Error for batch ${batchId}:`, statusData.error);
}
return;
}
// Use existing modal update logic - zero changes needed!
processModalStatusUpdate(playlistId, statusData);
});
// ENHANCED: Reset failure count on successful polling
globalPollingFailureCount = 0;
} catch (error) {
console.error('β [Global Polling] Batched request failed:', error);
// ENHANCED: Implement exponential backoff on failure
globalPollingFailureCount++;
if (globalPollingFailureCount >= 5) {
console.error(`π¨ [Global Polling] ${globalPollingFailureCount} consecutive failures, continuing with backoff`);
// Don't stop polling - just continue with exponential backoff
}
// Exponential backoff: increase interval temporarily
const backoffInterval = Math.min(globalPollingBaseInterval * Math.pow(2, globalPollingFailureCount - 1), 8000);
console.warn(`β οΈ [Global Polling] Failure ${globalPollingFailureCount}/5, backing off to ${backoffInterval}ms`);
// Temporarily adjust the polling interval
if (globalDownloadStatusPoller) {
clearInterval(globalDownloadStatusPoller);
globalDownloadStatusPoller = null;
// Restart with backoff interval
setTimeout(() => {
if (Object.keys(activeDownloadProcesses).length > 0) {
startGlobalDownloadPollingWithInterval(backoffInterval);
}
}, backoffInterval);
}
}
}, globalPollingBaseInterval); // Use base interval initially
}
function startGlobalDownloadPollingWithInterval(interval) {
if (globalDownloadStatusPoller) {
console.debug('π [Global Polling] Already running, skipping start with interval');
return;
}
console.log(`π [Global Polling] Starting with interval ${interval}ms`);
// Use the exact same logic as startGlobalDownloadPolling but with custom interval
globalDownloadStatusPoller = setInterval(async () => {
const activeBatchIds = [];
const batchToPlaylistMap = {};
let hasOpenWishlistModal = false;
Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => {
if (process.batchId && (process.status === 'running' || process.status === 'complete')) {
activeBatchIds.push(process.batchId);
batchToPlaylistMap[process.batchId] = playlistId;
}
// Check if there's an open wishlist modal (visible and idle/waiting)
if (playlistId === 'wishlist' && process.modalElement &&
process.modalElement.style.display === 'flex' &&
(!process.batchId || process.status !== 'running')) {
hasOpenWishlistModal = true;
}
});
// Special handling for open wishlist modal - check for new auto-processing
if (hasOpenWishlistModal) {
try {
const response = await fetch('/api/active-processes');
if (response.ok) {
const data = await response.json();
const processes = data.active_processes || [];
const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist');
if (serverWishlistProcess) {
console.log('π [Global Polling] Detected auto-processing for open wishlist modal - rehydrating');
await rehydrateModal(serverWishlistProcess, false); // false = not user-requested
}
}
} catch (error) {
console.debug('β οΈ [Global Polling] Failed to check for wishlist auto-processing:', error);
}
}
if (activeBatchIds.length === 0) {
console.debug('π [Global Polling] No active processes, continuing polling');
return;
}
try {
const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&');
const response = await fetch(`/api/download_status/batch?${queryParams}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.debug(`π [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`);
Object.entries(data.batches).forEach(([batchId, statusData]) => {
const playlistId = batchToPlaylistMap[batchId];
if (!playlistId || statusData.error) {
if (statusData.error) {
console.error(`β [Global Polling] Error for batch ${batchId}:`, statusData.error);
}
return;
}
processModalStatusUpdate(playlistId, statusData);
});
// Success - reset to normal interval if we were backing off
globalPollingFailureCount = 0;
if (interval !== globalPollingBaseInterval) {
console.log('β
[Global Polling] Recovered from backoff, returning to normal interval');
clearInterval(globalDownloadStatusPoller);
globalDownloadStatusPoller = null;
startGlobalDownloadPolling(); // Restart with normal interval
}
} catch (error) {
console.error('β [Global Polling] Request failed:', error);
globalPollingFailureCount++;
if (globalPollingFailureCount >= 5) {
console.error(`π¨ [Global Polling] Too many failures, continuing with backoff`);
// Don't stop polling - just continue with exponential backoff
}
}
}, interval);
}
function stopGlobalDownloadPolling() {
if (globalDownloadStatusPoller) {
console.log('π [Global Polling] Stopping batched download status polling');
clearInterval(globalDownloadStatusPoller);
globalDownloadStatusPoller = null;
}
}
// --- Error tooltip for failed/cancelled downloads (fixed-position, escapes overflow) ---
function _getErrorTooltipPopup() {
let el = document.getElementById('error-tooltip-popup');
if (!el) {
el = document.createElement('div');
el.id = 'error-tooltip-popup';
document.body.appendChild(el);
}
return el;
}
function _hideErrorTooltip() {
const popup = document.getElementById('error-tooltip-popup');
if (popup) popup.classList.remove('visible');
}
function _ensureErrorTooltipListeners(statusEl) {
if (statusEl._errorTooltipBound) return;
statusEl._errorTooltipBound = true;
statusEl.addEventListener('mouseenter', function () {
const msg = this.dataset.errorMsg;
if (!msg || !this.offsetParent) return; // skip if element is hidden
const popup = _getErrorTooltipPopup();
popup.textContent = msg;
popup.classList.add('visible');
const rect = this.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
let left = rect.left + rect.width / 2 - popupRect.width / 2;
let top = rect.top - popupRect.height - 10;
// Keep within viewport
if (left < 8) left = 8;
if (left + popupRect.width > window.innerWidth - 8) left = window.innerWidth - 8 - popupRect.width;
if (top < 8) { top = rect.bottom + 10; } // flip below if no room above
popup.style.left = left + 'px';
popup.style.top = top + 'px';
});
statusEl.addEventListener('mouseleave', _hideErrorTooltip);
// Dismiss tooltip when the scrollable modal body scrolls
const scrollParent = statusEl.closest('.download-missing-modal-body');
if (scrollParent && !scrollParent._errorTooltipScrollBound) {
scrollParent._errorTooltipScrollBound = true;
scrollParent.addEventListener('scroll', _hideErrorTooltip, { passive: true });
}
}
function _ensureCandidatesClickListener(statusEl) {
if (statusEl._candidatesClickBound) return;
statusEl._candidatesClickBound = true;
statusEl.addEventListener('click', function (e) {
e.stopPropagation();
_hideErrorTooltip();
const taskId = this.dataset.taskId;
if (!taskId) return;
// Decide at click-time from dataset set each render: completed and
// quarantined rows open the rich track-detail modal (it carries the
// play/listen + Accept/Search actions); plain failed/not-found go
// straight to the search modal.
if (this.dataset.detailOpen && typeof openTrackDetail === 'function') {
openTrackDetail(taskId);
} else {
showCandidatesModal(taskId);
}
});
}
async function showCandidatesModal(taskId) {
try {
const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/candidates`);
if (!resp.ok) { console.error('Failed to fetch candidates:', resp.status); return; }
const data = await resp.json();
_renderCandidatesModal(data);
} catch (err) {
console.error('Error fetching candidates:', err);
}
}
// Format helpers used by both auto-candidates and manual-search rendering.
function _candidatesFmtSize(bytes) {
if (!bytes) return '-';
const units = ['B', 'KB', 'MB', 'GB'];
let s = bytes, u = 0;
while (s >= 1024 && u < units.length - 1) { s /= 1024; u++; }
return `${s.toFixed(1)} ${units[u]}`;
}
function _candidatesFmtDur(ms) {
if (!ms) return '-';
const sec = Math.floor(ms / 1000);
return `${Math.floor(sec / 60)}:${(sec % 60).toString().padStart(2, '0')}`;
}
// Build a single for the candidates table. ``rowClass`` lets the
// manual-search renderer distinguish its rows from the auto-candidates
// rows (different click binding scope). ``showSourceBadge`` adds a small
// per-row source pill β used in hybrid "All sources" mode where the user
// otherwise can't tell which source a row came from.
// Display label for a candidate's filename. Encoded ``id||title`` sources
// (youtube/tidal/qobuz/hifi) carry the title after ``||`` β a '/' in that title
// is part of the name, NOT a path separator, so it must not be basename-split
// (issue #835: "YouSeeBIGGIRL/T:T" was showing as just "T:T"). Real file paths
// (Soulseek) keep the rightmost-segment basename.
function _ssShortFileLabel(filename) {
if (!filename) return '-';
if (filename.includes('||')) return filename.split('||').slice(1).join('||');
return filename.split(/[/\\]/).pop();
}
function _renderCandidateRow(c, index, rowClass, showSourceBadge) {
const shortFile = _ssShortFileLabel(c.filename);
const qBadge = c.quality
? `${c.quality.toUpperCase()} `
: '';
const sourceBadge = (showSourceBadge && c.source)
? `${escapeHtml(c.source)} `
: '';
return `
${index + 1}
${sourceBadge}${escapeHtml(shortFile)}
${qBadge}${c.bitrate ? ` ${c.bitrate}kbps` : ''}
${_candidatesFmtSize(c.size)}
${_candidatesFmtDur(c.duration)}
${escapeHtml(c.username || '-')}
β¬
`;
}
function _renderCandidatesModal(data) {
let overlay = document.getElementById('candidates-modal-overlay');
if (overlay) overlay.remove();
const trackName = data.track_info?.name || 'Unknown Track';
const trackArtist = data.track_info?.artist || 'Unknown Artist';
const candidates = data.candidates || [];
const errorMsg = data.error_message || '';
const downloadMode = data.download_mode || 'soulseek';
const availableSources = Array.isArray(data.available_sources) ? data.available_sources : [];
// Hybrid mode shows the dropdown; everything else implies a single source.
const isHybrid = downloadMode === 'hybrid';
let tableRows = '';
if (candidates.length === 0) {
tableRows = `
No candidates were found during search. `;
} else {
// Auto-candidates only show source badges in hybrid mode (where the
// user can't infer source from the dropdown).
candidates.forEach((c, i) => {
tableRows += _renderCandidateRow(c, i, 'candidates-row-auto', isHybrid);
});
}
// ----- Manual search bar -----
let sourceControl;
if (isHybrid && availableSources.length > 0) {
const optionsHtml = ['All sources ']
.concat(availableSources.map(s =>
`${escapeHtml(s.label)} `
))
.join('');
sourceControl = `${optionsHtml} `;
} else {
// Single-source mode β render a small static label, not a dropdown.
const onlySrc = availableSources[0];
const label = onlySrc ? onlySrc.label : (downloadMode || 'configured source');
sourceControl = `Searching ${escapeHtml(label)} `;
}
const manualSearchHtml = `
Manual search
${sourceControl}
Search
Type at least 2 characters
`;
overlay = document.createElement('div');
overlay.id = 'candidates-modal-overlay';
overlay.className = 'candidates-modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) closeCandidatesModal(); };
overlay.innerHTML = `
Search Results
${escapeHtml(trackName)} β ${escapeHtml(trackArtist)}
✕
${errorMsg ? `
${escapeHtml(errorMsg)}
` : ''}
${manualSearchHtml}
${candidates.length} candidate${candidates.length !== 1 ? 's' : ''} found${candidates.length > 0 ? ' but none passed filters' : ''}
# File Quality Size Duration User
${tableRows}
`;
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('visible'));
// Bind auto-candidate download buttons (existing behavior, scoped to
// .candidates-row-auto so manual-search rows don't double-trigger).
overlay.querySelectorAll('.candidates-row-auto .candidates-download-btn').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index);
const c = candidates[idx];
if (c) downloadCandidate(data.task_id, c, trackName);
});
});
// Wire manual search controls.
_wireManualSearch(overlay, data.task_id, trackName, isHybrid);
}
// Manual-search wiring β input/button/dropdown. Kept separate from
// _renderCandidatesModal so the existing render path stays readable and
// any future refactor can lift this into its own module.
function _wireManualSearch(overlay, taskId, trackName, isHybrid) {
const input = overlay.querySelector('#candidates-manual-search-input');
const button = overlay.querySelector('#candidates-manual-search-btn');
const hint = overlay.querySelector('#candidates-manual-search-hint');
const resultsContainer = overlay.querySelector('#candidates-manual-search-results');
const sourceSelect = overlay.querySelector('#candidates-manual-source');
if (!input || !button || !resultsContainer) return;
// Aggregated results across all source streams for the current query.
// Cleared at the start of each new search.
let currentResults = [];
let inFlight = false;
let abortController = null;
const updateButtonState = () => {
const q = (input.value || '').trim();
const tooShort = q.length < 2;
button.disabled = tooShort || inFlight;
if (hint) {
if (tooShort) {
hint.textContent = 'Type at least 2 characters';
hint.style.display = '';
} else {
hint.style.display = 'none';
}
}
};
const _renderTableShell = (query) => {
resultsContainer.innerHTML = `
Searching...
# File Quality Size Duration User
`;
};
const _appendRows = (newCandidates, query) => {
if (!newCandidates || newCandidates.length === 0) return;
const startIdx = currentResults.length;
currentResults = currentResults.concat(newCandidates);
const wrapper = resultsContainer.querySelector('#candidates-manual-table-wrapper');
const tbody = resultsContainer.querySelector('#candidates-manual-tbody');
const statusEl = resultsContainer.querySelector('#candidates-manual-search-status');
if (!tbody || !wrapper) return;
let rowsHtml = '';
newCandidates.forEach((c, i) => {
rowsHtml += _renderCandidateRow(c, startIdx + i, 'candidates-row-manual', isHybrid);
});
tbody.insertAdjacentHTML('beforeend', rowsHtml);
wrapper.style.display = '';
if (statusEl) {
statusEl.textContent = `${currentResults.length} result${currentResults.length !== 1 ? 's' : ''} so far...`;
}
// Wire newly-appended buttons
tbody.querySelectorAll('.candidates-download-btn').forEach(btn => {
if (btn._candidatesWired) return;
btn._candidatesWired = true;
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index);
const c = currentResults[idx];
if (c) downloadCandidate(taskId, c, trackName);
});
});
};
const _setStatus = (text) => {
const statusEl = resultsContainer.querySelector('#candidates-manual-search-status');
if (statusEl) statusEl.textContent = text;
};
const _setError = (msg) => {
resultsContainer.innerHTML = `${escapeHtml(msg)}
`;
};
const runSearch = async () => {
const q = (input.value || '').trim();
if (q.length < 2 || inFlight) return;
if (abortController) {
try { abortController.abort(); } catch (_) { }
}
abortController = new AbortController();
const source = sourceSelect ? sourceSelect.value : 'all';
inFlight = true;
button.disabled = true;
const originalLabel = button.textContent;
button.textContent = 'Searching...';
currentResults = [];
_renderTableShell(q);
try {
const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/manual-search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, source: source }),
signal: abortController.signal,
});
if (!resp.ok) {
let errMsg = 'Search failed';
try {
const payload = await resp.json();
if (payload && payload.error) errMsg = payload.error;
} catch (_) { }
_setError(errMsg);
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const errors = [];
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lineEnd;
while ((lineEnd = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, lineEnd).trim();
buffer = buffer.slice(lineEnd + 1);
if (!line) continue;
let msg;
try { msg = JSON.parse(line); } catch (_) { continue; }
if (msg.type === 'source_results') {
_appendRows(msg.candidates || [], q);
} else if (msg.type === 'source_error') {
errors.push(`${msg.source}: ${msg.error}`);
} else if (msg.type === 'done') {
if (currentResults.length === 0) {
const errorNote = errors.length
? `${errors.length} source${errors.length !== 1 ? 's' : ''} failed
`
: '';
resultsContainer.innerHTML = `
No manual search results for "${escapeHtml(q)}"
${errorNote}`;
} else {
_setStatus(`${currentResults.length} result${currentResults.length !== 1 ? 's' : ''}`);
}
}
}
}
} catch (err) {
if (err.name === 'AbortError') return;
console.error('Manual search failed:', err);
_setError('Search request failed');
} finally {
inFlight = false;
button.textContent = originalLabel;
updateButtonState();
}
};
input.addEventListener('input', updateButtonState);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !button.disabled) {
e.preventDefault();
runSearch();
}
});
button.addEventListener('click', runSearch);
updateButtonState();
}
async function downloadCandidate(taskId, candidate, trackName) {
if (!await showConfirmDialog({ title: 'Download File', message: `Download this file as "${trackName}"?\n\n${candidate.filename?.split(/[/\\]/).pop() || 'Unknown file'}\nfrom ${candidate.username || 'Unknown user'}`, confirmText: 'Download' })) return;
try {
const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/download-candidate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(candidate)
});
const result = await resp.json();
if (result.success) {
closeCandidatesModal();
showToast(result.message || 'Download initiated', 'success');
} else {
showToast(`Failed: ${result.error}`, 'error');
}
} catch (err) {
console.error('Error initiating manual download:', err);
showToast('Failed to initiate download', 'error');
}
}
async function approveQuarantineFromDownloadRow(button) {
const entryId = button?.dataset?.entryId || '';
const taskId = button?.dataset?.taskId || '';
if (!entryId) {
showToast('Open Quarantine to approve this file.', 'warning');
return;
}
const confirmed = await showConfirmDialog({
title: 'Approve Quarantined File',
message: 'Import this quarantined file and skip quarantine checks for this approved pass?',
confirmText: 'Approve & Import',
cancelText: 'Cancel',
});
if (!confirmed) return;
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Approving...';
try {
const response = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId }),
});
const data = await response.json();
if (data.success) {
showToast('Approved quarantined file. Re-running post-processing.', 'success');
} else {
showToast(`Approve failed: ${data.error || 'Unknown error'}`, 'error');
button.disabled = false;
button.textContent = originalText;
}
} catch (error) {
showToast(`Approve failed: ${error.message}`, 'error');
button.disabled = false;
button.textContent = originalText;
}
}
// Quarantine actions (Listen / Accept & Import / Search) now live in the
// track-detail modal (static/track-detail.js), which a quarantined row opens
// via _ensureCandidatesClickListener + dataset.detailOpen.
function closeCandidatesModal() {
const overlay = document.getElementById('candidates-modal-overlay');
if (overlay) {
overlay.classList.remove('visible');
setTimeout(() => overlay.remove(), 300);
}
}
function _downloadModalBundleProgressPercent(bundle) {
if (!bundle) return 0;
const raw = bundle.progress_percent ?? bundle.progress ?? 0;
let progress = Number(raw);
if (!Number.isFinite(progress)) progress = 0;
if (progress <= 1) progress *= 100;
return Math.max(0, Math.min(100, Math.round(progress)));
}
function _downloadModalFormatBytes(bytes) {
const value = Number(bytes);
if (!Number.isFinite(value) || value <= 0) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = value;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
const decimals = size >= 10 || unit === 0 ? 0 : 1;
return `${size.toFixed(decimals)} ${units[unit]}`;
}
function _downloadModalFormatSpeed(bytesPerSecond) {
const formatted = _downloadModalFormatBytes(bytesPerSecond);
return formatted ? `${formatted}/s` : '';
}
function _downloadModalSourceLabel(source) {
const labels = {
torrent: 'Torrent',
usenet: 'Usenet',
soulseek: 'Soulseek',
youtube: 'YouTube',
tidal: 'Tidal',
qobuz: 'Qobuz',
hifi: 'HiFi',
deezer_dl: 'Deezer',
amazon: 'Amazon',
lidarr: 'Lidarr',
soundcloud: 'SoundCloud'
};
const key = String(source || '').toLowerCase();
return labels[key] || (source ? String(source) : 'Release');
}
function _downloadModalBundleStateLabel(state) {
const labels = {
searching: 'searching for release',
downloading: 'downloading release',
staged: 'matching tracks',
failed: 'release failed'
};
const key = String(state || '').toLowerCase();
return labels[key] || (state ? String(state).replace(/_/g, ' ') : 'downloading release');
}
function _downloadModalBundleProgressText(bundle) {
const percent = _downloadModalBundleProgressPercent(bundle);
const source = _downloadModalSourceLabel(bundle && bundle.source);
const state = _downloadModalBundleStateLabel(bundle && bundle.state);
const release = bundle && bundle.release ? ` - ${bundle.release}` : '';
const speed = _downloadModalFormatSpeed(bundle && bundle.speed);
const size = _downloadModalFormatBytes(bundle && bundle.size);
const detail = speed || size ? ` (${[speed, size].filter(Boolean).join(' of ')})` : '';
return `${source} ${state} ${percent}%${release}${detail}`;
}
function processModalStatusUpdate(playlistId, data) {
// This function contains ALL the existing polling logic from startModalDownloadPolling
// Extracted so it can be called from both individual and batched polling
const process = activeDownloadProcesses[playlistId];
if (!process) {
console.debug(`β οΈ [Status Update] No process found for ${playlistId}, skipping update`);
return;
}
if (data.error) {
console.error(`β [Status Update] Error for ${playlistId}: ${data.error}`);
return;
}
// ENHANCED: Validate response data to prevent UI corruption
if (!data || typeof data !== 'object') {
console.error(`β [Status Update] Invalid data for ${playlistId}:`, data);
return;
}
// ENHANCED: Validate task data structure
if (data.tasks && !Array.isArray(data.tasks)) {
console.error(`β [Status Update] Invalid tasks data for ${playlistId} - not an array:`, data.tasks);
return;
}
console.debug(`π [Status Update] Processing update for ${playlistId}: phase=${data.phase}, tasks=${(data.tasks || []).length}`);
// Note: Wishlist modal visibility is now managed by handleWishlistButtonClick() only
// Auto-show logic has been simplified to prevent conflicts
if (data.phase === 'queued') {
// Submitted to the executor but no worker has picked it up yet.
// ``missing_download_executor`` is bounded (max_workers=3 by
// default) so wishlist runs with N > 3 sub-batches park the
// rest at this phase. Show distinct text so users don't think
// 26 batches are all in-flight at once.
const total = data.analysis_progress?.total || 0;
const elText = document.getElementById(`analysis-progress-text-${playlistId}`);
const elFill = document.getElementById(`analysis-progress-fill-${playlistId}`);
if (elText) elText.textContent = `Queued β waiting for worker (${total} tracks)`;
if (elFill) elFill.style.width = '0%';
} else if (data.phase === 'analysis') {
const progress = data.analysis_progress;
const percent = progress.total > 0 ? (progress.processed / progress.total) * 100 : 0;
document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = `${percent}%`;
document.getElementById(`analysis-progress-text-${playlistId}`).textContent =
`${progress.processed}/${progress.total} tracks analyzed`;
if (data.analysis_results) {
updateTrackAnalysisResults(playlistId, data.analysis_results);
// Update stats when we first get analysis results
const foundCount = data.analysis_results.filter(r => r.found).length;
const missingCount = data.analysis_results.filter(r => !r.found).length;
document.getElementById(`stat-found-${playlistId}`).textContent = foundCount;
document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount;
// Auto-save M3U file for playlists after analysis
autoSavePlaylistM3U(playlistId);
}
} else if (data.phase === 'album_downloading') {
const analysisFill = document.getElementById(`analysis-progress-fill-${playlistId}`);
const analysisText = document.getElementById(`analysis-progress-text-${playlistId}`);
if (analysisFill) analysisFill.style.width = '100%';
if (analysisText) analysisText.textContent = 'Analysis complete!';
const bundle = data.album_bundle || {};
const percent = _downloadModalBundleProgressPercent(bundle);
const downloadFill = document.getElementById(`download-progress-fill-${playlistId}`);
const downloadText = document.getElementById(`download-progress-text-${playlistId}`);
if (downloadFill) downloadFill.style.width = `${percent}%`;
if (downloadText) {
downloadText.textContent = _downloadModalBundleProgressText(bundle);
downloadText.title = 'SoulSync downloads one album release first, then matches the selected tracks from the staged files.';
}
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
if (modal) {
modal.querySelectorAll('[id^="download-"]').forEach(statusEl => {
if (!statusEl.id.startsWith(`download-${playlistId}-`)) return;
if (!statusEl.textContent || statusEl.textContent === '-' || statusEl.textContent.includes('Pending')) {
statusEl.textContent = 'Waiting for release';
statusEl.classList.add('album-bundle-waiting');
statusEl.title = 'The album release is downloading first. Tracks will move to processing once SoulSync can match files from it.';
}
});
}
} else if (data.phase === 'downloading' || data.phase === 'complete' || data.phase === 'error') {
console.debug(`π [Status Update] Processing ${data.phase} phase for playlistId: ${playlistId}, tasks: ${(data.tasks || []).length}`);
if (document.getElementById(`analysis-progress-fill-${playlistId}`).style.width !== '100%') {
document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = '100%';
document.getElementById(`analysis-progress-text-${playlistId}`).textContent = 'Analysis complete!';
if (data.analysis_results) {
updateTrackAnalysisResults(playlistId, data.analysis_results);
const foundCount = data.analysis_results.filter(r => r.found).length;
const missingCount = data.analysis_results.filter(r => !r.found).length;
document.getElementById(`stat-found-${playlistId}`).textContent = foundCount;
document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount;
}
}
const missingTracks = (data.analysis_results || []).filter(r => !r.found);
const missingCount = missingTracks.length;
let completedCount = 0;
let failedOrCancelledCount = 0;
let notFoundCount = 0;
// Verify modal exists before processing tasks
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
if (!modal) {
console.error(`β [Status Update] Modal not found: download-missing-modal-${playlistId}`);
return;
}
// Update download progress text immediately when entering downloading phase
// This handles the case where tasks array is empty or still being populated
const downloadProgressText = document.getElementById(`download-progress-text-${playlistId}`);
if (data.phase === 'downloading' && missingCount > 0 && (!data.tasks || data.tasks.length === 0)) {
// No tasks yet, but we're in downloading phase with missing tracks
if (downloadProgressText) {
downloadProgressText.textContent = 'Preparing downloads...';
console.log(`π₯ [Download Phase] Preparing ${missingCount} downloads...`);
}
}
(data.tasks || []).forEach(task => {
const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${task.track_index}"]`);
if (!row) {
console.debug(`β [Status Update] Row not found for playlistId: ${playlistId}, track_index: ${task.track_index}`);
return;
}
// V2 SYSTEM: Check for persistent cancel state from backend
const isV2Task = task.playlist_id !== undefined; // V2 tasks have playlist_id
const cancelRequested = task.cancel_requested || false;
const uiState = task.ui_state || 'normal';
// Legacy protection for old system compatibility
if (row.dataset.locallyCancelled === 'true' && !isV2Task) {
failedOrCancelledCount++;
return; // Only skip for legacy system tasks
}
// Mark row with V2 system info
if (isV2Task) {
row.dataset.useV2System = 'true';
row.dataset.cancelRequested = cancelRequested.toString();
row.dataset.uiState = uiState;
}
row.dataset.taskId = task.task_id;
const statusEl = document.getElementById(`download-${playlistId}-${task.track_index}`);
const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`);
let statusText = '';
let isQuarantinedTask = false;
// V2 SYSTEM: Handle UI state override for cancelling tasks
if (isV2Task && uiState === 'cancelling' && task.status !== 'cancelled') {
statusText = 'π Cancelling...';
} else {
switch (task.status) {
case 'pending': statusText = 'βΈοΈ Pending'; break;
case 'searching':
statusText = 'π Searching...';
// Quarantine-retry engine: show which attempt we're on
// ("retry 2/5") while it walks the next-best candidates.
if (task.retry_info) statusText += ` π retry ${task.retry_info}`;
break;
case 'downloading':
statusText = `β¬ Downloading... ${Math.round(task.progress || 0)}%`;
if (task.retry_info) statusText += ` π retry ${task.retry_info}`;
break;
case 'post_processing': statusText = 'β Processing...'; break;
case 'completed': {
statusText = 'β
Completed';
// Verification badge β how this file passed verification:
// verified = clean AcoustID pass; unverified = couldn't be
// hard-confirmed (cross-script/ambiguous/no fingerprint match);
// force_imported = accepted as best candidate after the retry
// budget was exhausted (version-mismatch fallback).
if (task.verification_status === 'force_imported') {
statusText += ' β ';
} else if (task.verification_status === 'unverified') {
statusText += ' β ';
} else if (task.verification_status === 'verified') {
statusText += ' β ';
} else if (task.verification_status === 'human_verified') {
statusText += ' π‘β ';
}
completedCount++;
break;
}
case 'not_found': statusText = 'π Not Found'; notFoundCount++; break;
case 'failed': {
// Distinguish quarantine outcomes from generic
// failures β the file is recoverable, not lost.
const _em = (task.error_message || '').toLowerCase();
if (_em.includes('integrity check failed') || _em.includes('bit depth filter') || _em.includes('verification failed') || _em.includes('quality filter') || _em.includes('audio guard') || _em.includes('silence guard') || _em.includes('quarantin')) {
isQuarantinedTask = true;
statusText = 'π‘οΈ Quarantined';
} else {
statusText = 'β Failed';
}
failedOrCancelledCount++;
break;
}
case 'cancelled': statusText = 'π« Cancelled'; failedOrCancelledCount++; break;
default: statusText = `βͺ ${task.status}`; break;
}
}
if (statusEl) {
statusEl.classList.remove('has-error-tooltip');
statusEl.removeAttribute('title');
statusEl.removeAttribute('data-error-msg');
// Clear clickable/quarantine state each render; the failure
// branch below re-adds it when still applicable. Without this a
// task that flips failed/quarantined -> completed (e.g. after
// Accept & Import) keeps a stale chooser on the cell.
statusEl.classList.remove('has-candidates');
delete statusEl.dataset.quarantineEntryId;
delete statusEl.dataset.quarantineReason;
delete statusEl.dataset.quarantineTrack;
delete statusEl.dataset.detailOpen;
// statusText is static markup only; the verif-badge span is the
// one case that needs HTML. Everything else stays textContent
// (XSS-safe default).
if (statusText.includes('class="verif-badge')) {
statusEl.innerHTML = statusText;
} else {
statusEl.textContent = statusText;
}
// Visual-only hooks: the cell carries its state for the badge
// styling, the row glows while a track is actively working.
statusEl.dataset.state = isQuarantinedTask ? 'quarantined'
: (isV2Task && uiState === 'cancelling' ? 'cancelling' : task.status);
row.classList.toggle('row-working',
['searching', 'downloading', 'post_processing'].includes(task.status));
if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) {
statusEl.classList.add('has-error-tooltip');
statusEl.dataset.errorMsg = task.error_message;
_ensureErrorTooltipListeners(statusEl);
}
// Completed rows are clickable into the rich track-detail modal
// (play, location, AcoustID verdict, provenance).
if (task.status === 'completed') {
statusEl.classList.add('has-candidates');
statusEl.dataset.taskId = task.task_id;
statusEl.dataset.detailOpen = '1';
_ensureCandidatesClickListener(statusEl);
} else if (task.status === 'not_found' || task.status === 'failed' || task.status === 'cancelled') {
// Clickable to recover: quarantined -> track-detail modal
// (Listen / Accept / Search); plain failed/not-found ->
// straight to the search modal. detailOpen is set/cleared
// each render so a row that changes kind stays correct.
statusEl.classList.add('has-candidates');
statusEl.dataset.taskId = task.task_id;
if (isQuarantinedTask && task.quarantine_entry_id) {
statusEl.dataset.detailOpen = '1';
}
_ensureCandidatesClickListener(statusEl);
}
console.debug(`β
[Status Update] Updated track ${task.track_index} to: ${statusText}${isV2Task ? ' (V2)' : ''}`);
} else {
console.warn(`β [Status Update] Status element not found: download-${playlistId}-${task.track_index}`);
}
// V2 SYSTEM: Smart button management with persistent state awareness
if (actionsEl && !['completed', 'failed', 'cancelled', 'not_found', 'post_processing'].includes(task.status)) {
// Check if we're in a cancelling state
if (isV2Task && uiState === 'cancelling') {
actionsEl.innerHTML = 'Cancelling... ';
} else {
// Create V2 cancel button for all active tasks
const onclickHandler = isV2Task ? 'cancelTrackDownloadV2' : 'cancelTrackDownload';
actionsEl.innerHTML = `Γ `;
}
} else if (actionsEl && task.status === 'failed' && isQuarantinedTask && task.quarantine_entry_id) {
const entryId = escapeHtml(task.quarantine_entry_id);
actionsEl.innerHTML = `Approve `;
const approveBtn = actionsEl.querySelector('.approve-quarantine-inline-btn');
if (approveBtn) {
approveBtn.addEventListener('click', () => approveQuarantineFromDownloadRow(approveBtn));
}
} else if (actionsEl && ['completed', 'failed', 'cancelled', 'not_found', 'post_processing'].includes(task.status)) {
actionsEl.innerHTML = '-'; // No actions available for terminal or processing states
}
});
// ENHANCED: Validate worker counts from server data
const serverActiveWorkers = data.active_count || 0;
const maxWorkers = data.max_concurrent || 3;
// V2 SYSTEM: Simplified worker counting - backend is authoritative
// Count active tasks, excluding locally cancelled legacy tasks only
const clientActiveWorkers = (data.tasks || []).filter(task => {
const row = document.querySelector(`tr[data-track-index="${task.track_index}"]`);
const isLegacyCancelled = row && row.dataset.locallyCancelled === 'true' && !row.dataset.useV2System;
return ['searching', 'downloading', 'queued'].includes(task.status) && !isLegacyCancelled;
}).length;
// Log discrepancies for debugging
if (serverActiveWorkers !== clientActiveWorkers) {
console.warn(`π [Worker Validation] ${playlistId}: server reports ${serverActiveWorkers} active, client sees ${clientActiveWorkers} active tasks`);
// If server reports 0 but client sees active tasks, this might indicate ghost workers were fixed
if (serverActiveWorkers === 0 && clientActiveWorkers > 0) {
console.warn(`π¨ [Worker Validation] Server reports 0 workers but client sees ${clientActiveWorkers} active tasks - potential UI desync`);
}
}
console.debug(`π [Worker Status] ${playlistId}: ${serverActiveWorkers}/${maxWorkers} active workers, ${clientActiveWorkers} client-side active tasks`);
const totalFinished = completedCount + failedOrCancelledCount + notFoundCount;
const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 0;
document.getElementById(`download-progress-fill-${playlistId}`).style.width = `${progressPercent}%`;
document.getElementById(`download-progress-text-${playlistId}`).textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`;
document.getElementById(`stat-downloaded-${playlistId}`).textContent = completedCount;
// Auto-save M3U file once when all downloads finish (not on every poll cycle).
// Previously this fired on EVERY 2-second poll when completedCount > 0, flooding
// the server with heavyweight M3U generation requests that exhausted Flask threads
// and caused the batch status endpoint to hang β killing the poller.
// CLIENT-SIDE COMPLETION: Only complete when ALL task rows in the UI reflect a terminal state.
// Using totalFinished (derived from DOM updates in THIS render pass) prevents premature
// completion when the server sends phase='complete' before all rows have been updated.
const allTracksFinished = totalFinished >= missingCount && missingCount > 0 && totalFinished > 0;
// Extra guard: require the server to also report no active tasks
const serverHasActiveWork = (data.tasks || []).some(t =>
['downloading', 'searching', 'queued', 'pending', 'post_processing'].includes(t.status));
if (allTracksFinished && !serverHasActiveWork && process.status !== 'complete') {
console.log(`π― [Client Completion] All ${totalFinished}/${missingCount} tracks finished - completing modal locally`);
// Hide cancel button and mark as complete
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none';
process.status = 'complete';
updatePlaylistCardUI(playlistId);
// Save M3U once on completion (not during progress polling)
if (completedCount > 0) {
autoSavePlaylistM3U(playlistId);
}
// Show the force download toggle again
const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container');
if (forceToggleContainer) {
forceToggleContainer.style.display = 'flex';
}
// Set album to downloaded status if this is an artist album
if (playlistId.startsWith('artist_album_')) {
const parts = playlistId.split('_');
if (parts.length >= 4) {
const albumId = parts.slice(3).join('_');
setTimeout(() => setAlbumDownloadedStatus(albumId), 500); // Small delay to ensure UI updates
}
}
// Update mirrored playlist card phase on client-side completion
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'download_complete');
}
}
// Auto-save final M3U file for playlists
autoSavePlaylistM3U(playlistId);
// Show completion message
let completionParts = [`${completedCount} downloaded`];
if (notFoundCount > 0) completionParts.push(`${notFoundCount} not found`);
if (failedOrCancelledCount > 0) completionParts.push(`${failedOrCancelledCount} failed`);
const completionMessage = `Download complete! ${completionParts.join(', ')}.`;
showToast(completionMessage, 'success');
// Refresh server playlists tab so it reflects newly synced tracks
if (typeof loadServerPlaylists === 'function') {
setTimeout(() => loadServerPlaylists(), 2000);
}
// Keep visible wishlist results open so failed tracks can be reviewed.
if (playlistId === 'wishlist') {
console.log('[Wishlist] Leaving completed wishlist modal open for failed-track review');
}
// Check if any other processes still need polling
checkAndCleanupGlobalPolling();
return; // Skip waiting for backend signal
}
// FIXED: Only trigger completion logic when backend actually reports batch as complete
// Don't assume completion based on task counts - let backend determine when truly complete
if (data.phase === 'complete' || data.phase === 'error') {
// Enhanced check for background auto-processing for wishlist
const isWishlist = (playlistId === 'wishlist');
const isModalHidden = (process.modalElement && process.modalElement.style.display === 'none');
const isAutoInitiated = data.auto_initiated || false; // Server indicates if batch was auto-started
const isBackgroundWishlist = isWishlist && (isModalHidden || isAutoInitiated);
// Note: Auto-show logic removed - wishlist modal visibility managed by user interaction only
if (data.phase === 'cancelled') {
if (process.status !== 'cancelled') {
process.status = 'cancelled';
// Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on cancel
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'discovered');
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'discovered');
}
}
showToast(`Process cancelled for ${process.playlist.name}.`, 'info');
}
} else if (data.phase === 'error') {
if (process.status !== 'complete') {
process.status = 'complete';
updatePlaylistCardUI(playlistId); // Update card to show ready for review
// Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on error
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'discovered');
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'discovered');
}
}
showToast(`Process for ${process.playlist.name} failed!`, 'error');
}
} else {
if (process.status !== 'complete') {
process.status = 'complete';
updatePlaylistCardUI(playlistId); // Update card to show ready for review
// Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'download_complete');
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'download_complete');
}
}
// Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist
if (playlistId.startsWith('tidal_')) {
const tidalPlaylistId = playlistId.replace('tidal_', '');
if (tidalPlaylistStates[tidalPlaylistId]) {
tidalPlaylistStates[tidalPlaylistId].phase = 'download_complete';
// Store the download process ID for potential modal rehydration
tidalPlaylistStates[tidalPlaylistId].download_process_id = process.batchId;
updateTidalCardPhase(tidalPlaylistId, 'download_complete');
console.log(`β
[Status Complete] Updated Tidal playlist ${tidalPlaylistId} to download_complete phase`);
}
}
// Update Beatport chart phase to 'download_complete' if this is a Beatport chart
if (playlistId.startsWith('beatport_')) {
const urlHash = playlistId.replace('beatport_', '');
const state = youtubePlaylistStates[urlHash];
if (state && state.is_beatport_playlist) {
const chartHash = state.beatport_chart_hash || urlHash;
// Update frontend states
state.phase = 'download_complete';
state.download_process_id = process.batchId;
if (beatportChartStates[chartHash]) {
beatportChartStates[chartHash].phase = 'download_complete';
}
// Update card UI
updateBeatportCardPhase(chartHash, 'download_complete');
// Update backend state
try {
fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phase: 'download_complete',
download_process_id: process.batchId
})
});
} catch (error) {
console.warn('β οΈ Error updating backend Beatport phase to download_complete:', error);
}
console.log(`β
[Status Complete] Updated Beatport chart ${chartHash} to download_complete phase`);
}
}
// Handle background wishlist processing completion specially
if (isBackgroundWishlist) {
console.log(`π Background wishlist processing complete: ${completedCount} downloaded, ${notFoundCount} not found, ${failedOrCancelledCount} failed`);
// Reset modal to idle state to prevent "complete" phase disruption
setTimeout(() => {
resetWishlistModalToIdleState();
// Server-side auto-processing will handle next cycle automatically
}, 500);
return; // Skip normal completion handling
}
// Show completion summary with wishlist stats (matching sync.py behavior)
let completionMessage = `Process complete for ${process.playlist.name}!`;
let messageType = 'success';
// Check for wishlist summary from backend (added when failed/cancelled tracks are processed)
if (data.wishlist_summary) {
const summary = data.wishlist_summary;
let summaryParts = [`Downloaded: ${completedCount}`];
if (notFoundCount > 0) summaryParts.push(`Not Found: ${notFoundCount}`);
if (failedOrCancelledCount > 0) summaryParts.push(`Failed: ${failedOrCancelledCount}`);
completionMessage = `Download process complete! ${summaryParts.join(', ')}.`;
if (summary.tracks_added > 0) {
completionMessage += ` Added ${summary.tracks_added} failed track${summary.tracks_added !== 1 ? 's' : ''} to wishlist for automatic retry.`;
} else if (summary.total_failed > 0) {
completionMessage += ` ${summary.total_failed} track${summary.total_failed !== 1 ? 's' : ''} could not be added to wishlist.`;
messageType = 'warning';
}
}
showToast(completionMessage, messageType);
}
}
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none';
// Mark process as complete and trigger cleanup check
process.status = 'complete';
updatePlaylistCardUI(playlistId);
// Check if any other processes still need polling
checkAndCleanupGlobalPolling();
}
}
}
function checkAndCleanupGlobalPolling() {
// Check if any processes still need polling
const hasActivePolling = Object.values(activeDownloadProcesses)
.some(p => p.batchId && p.status === 'running');
if (!hasActivePolling) {
console.debug('π§Ή [Cleanup] No more active processes, continuing polling');
// Keep polling active - no need to stop
}
}
// LEGACY FUNCTION: Keep for backward compatibility, but now uses global polling
function startModalDownloadPolling(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process || !process.batchId) return;
console.log(`π [Legacy Polling] Starting polling for ${playlistId}, delegating to global poller`);
// Clear any existing individual poller (cleanup)
if (process.poller) {
clearInterval(process.poller);
process.poller = null;
}
// Mark process as running to be picked up by global poller
process.status = 'running';
// Start global polling if not already running
startGlobalDownloadPolling();
// Create dummy poller for backward compatibility with cleanup functions
ensureLegacyCompatibility(playlistId);
}
// For backward compatibility with cleanup functions that expect process.poller
// Creates a dummy poller that will be cleaned up by the existing cleanup logic
function createLegacyPoller(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
// Create a dummy interval that just checks if the process is still active
// This ensures existing cleanup logic that calls clearInterval(process.poller) works
process.poller = setInterval(() => {
// This dummy poller doesn't do anything - global poller handles updates
if (!activeDownloadProcesses[playlistId] || process.status === 'complete') {
clearInterval(process.poller);
process.poller = null;
return;
}
}, 5000); // Very infrequent check, just for cleanup compatibility
}
// Call this to create the legacy poller after starting global polling
function ensureLegacyCompatibility(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (process && !process.poller) {
createLegacyPoller(playlistId);
}
}
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)) {
// Extract display title from filename (handle YouTube encoding)
let downloadTitle = '';
if (downloadInfo.filename) {
if ((downloadInfo.username === 'youtube' || downloadInfo.username === 'tidal' || downloadInfo.username === 'qobuz' || downloadInfo.username === 'hifi') && downloadInfo.filename.includes('||')) {
const parts = downloadInfo.filename.split('||');
downloadTitle = parts[1] || parts[0];
} else {
downloadTitle = 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
}
}
function toggleAllTrackSelections(playlistId, checked) {
const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`);
if (!tbody) return;
const checkboxes = tbody.querySelectorAll('.track-select-cb');
checkboxes.forEach(cb => { cb.checked = checked; });
updateTrackSelectionCount(playlistId);
}
function updateTrackSelectionCount(playlistId) {
const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`);
if (!tbody) return;
const allCbs = tbody.querySelectorAll('.track-select-cb');
const checkedCbs = tbody.querySelectorAll('.track-select-cb:checked');
const total = allCbs.length;
const selected = checkedCbs.length;
// Update selection count label
const countLabel = document.getElementById(`track-selection-count-${playlistId}`);
if (countLabel) {
countLabel.textContent = `${selected} / ${total} tracks selected`;
}
// Update select-all checkbox state
const selectAll = document.getElementById(`select-all-${playlistId}`);
if (selectAll) {
selectAll.checked = selected === total;
selectAll.indeterminate = selected > 0 && selected < total;
}
// Update row dimming
allCbs.forEach(cb => {
const row = cb.closest('tr');
if (row) row.classList.toggle('track-deselected', !cb.checked);
});
// Disable Begin Analysis and Add to Wishlist buttons when 0 selected
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
if (beginBtn) {
beginBtn.disabled = selected === 0;
}
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`);
if (wishlistBtn) {
wishlistBtn.disabled = selected === 0;
}
}
async function cancelAllOperations(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
// Prevent multiple cancel all operations
if (process.cancellingAll) {
console.log(`β οΈ Cancel All already in progress for ${playlistId}`);
return;
}
process.cancellingAll = true;
console.log(`π« Cancel All clicked for playlist ${playlistId} - closing modal and cleaning up server`);
showToast('Cancelling all operations and closing modal...', 'info');
// Mark process as complete immediately so polling stops
process.status = 'complete';
// Stop any active polling
if (process.poller) {
clearInterval(process.poller);
process.poller = null;
}
// Tell server to stop starting new downloads and clean up the batch
if (process.batchId) {
try {
// Cancel the batch (stops new downloads from starting)
const cancelResponse = await fetch(`/api/playlists/${process.batchId}/cancel_batch`, {
method: 'POST'
});
if (cancelResponse.ok) {
const cancelData = await cancelResponse.json();
console.log(`β
Server stopped new downloads for batch ${process.batchId}`);
}
} catch (error) {
console.warn('Error during server batch cancel:', error);
}
}
// Close the modal immediately - this will handle cleanup
closeDownloadMissingModal(playlistId);
showToast('Modal closed. Active downloads will finish in background.', 'success');
}
function resetToInitialState() {
// Reset UI
document.getElementById('begin-analysis-btn').style.display = 'inline-block';
document.getElementById('start-downloads-btn').style.display = 'none';
document.getElementById('cancel-all-btn').style.display = 'none';
// Reset progress bars
document.getElementById('analysis-progress-fill').style.width = '0%';
document.getElementById('download-progress-fill').style.width = '0%';
document.getElementById('analysis-progress-text').textContent = 'Ready to start';
document.getElementById('download-progress-text').textContent = 'Waiting for analysis';
// Reset stats
document.getElementById('stat-found').textContent = '-';
document.getElementById('stat-missing').textContent = '-';
document.getElementById('stat-downloaded').textContent = '0';
// Reset track table
const tbody = document.getElementById('download-tracks-tbody');
if (tbody) {
const rows = tbody.querySelectorAll('tr');
rows.forEach((row, index) => {
const matchElement = row.querySelector('.track-match-status');
const downloadElement = row.querySelector('.track-download-status');
const actionsElement = row.querySelector('.track-actions');
if (matchElement) {
matchElement.textContent = 'π Pending';
matchElement.className = 'track-match-status match-checking';
}
if (downloadElement) {
downloadElement.textContent = '-';
downloadElement.className = 'track-download-status';
}
if (actionsElement) {
actionsElement.textContent = '-';
}
});
}
// Reset state
activeAnalysisTaskId = null;
analysisResults = [];
missingTracks = [];
}
// ===============================
// NEW ATOMIC CANCEL SYSTEM V2
// ===============================
async function cancelTrackDownloadV2(playlistId, trackIndex) {
/**
* NEW ATOMIC CANCEL SYSTEM V2
*
* - No optimistic UI updates
* - Single API call handles everything atomically
* - Backend is single source of truth for all state
* - No race conditions or dual state management
*/
const process = activeDownloadProcesses[playlistId];
if (!process) {
console.warn(`β [Cancel V2] No process found for playlist: ${playlistId}`);
return;
}
const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${trackIndex}"]`);
if (!row) {
console.warn(`β [Cancel V2] No row found for track index: ${trackIndex}`);
return;
}
// Check if already in cancelling state
const statusEl = document.getElementById(`download-${playlistId}-${trackIndex}`);
const currentStatus = statusEl ? statusEl.textContent : '';
if (currentStatus.includes('Cancelling') || currentStatus.includes('Cancelled')) {
console.log(`β οΈ [Cancel V2] Task already being cancelled or cancelled: ${currentStatus}`);
return;
}
console.log(`π― [Cancel V2] Starting atomic cancel: playlist=${playlistId}, track=${trackIndex}`);
// V2 SYSTEM: Set temporary UI state - will be confirmed by server
row.dataset.uiState = 'cancelling';
// Show loading state only - no optimistic "cancelled" state
if (statusEl) {
statusEl.textContent = 'π Cancelling...';
}
// Disable the cancel button to prevent double-clicks
const actionsEl = document.getElementById(`actions-${playlistId}-${trackIndex}`);
if (actionsEl) {
actionsEl.innerHTML = 'Cancelling... ';
}
try {
const response = await fetch('/api/downloads/cancel_task_v2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_id: playlistId,
track_index: trackIndex
})
});
const data = await response.json();
if (data.success) {
console.log(`β
[Cancel V2] Successfully cancelled: ${data.task_info.track_name}`);
showToast(`Cancelled "${data.task_info.track_name}" and added to wishlist.`, 'success');
// Let the status polling system update the UI with server truth
// No manual UI updates - backend is authoritative
} else {
console.error(`β [Cancel V2] Cancel failed: ${data.error}`);
showToast(`Cancel failed: ${data.error}`, 'error');
// Reset UI to previous state on failure
row.dataset.uiState = 'normal'; // Reset UI state
if (statusEl) {
statusEl.textContent = 'β Cancel Failed';
}
if (actionsEl) {
actionsEl.innerHTML = `Γ `;
}
}
} catch (error) {
console.error(`β [Cancel V2] Network/API error:`, error);
showToast(`Cancel request failed: ${error.message}`, 'error');
// Reset UI on network error
row.dataset.uiState = 'normal'; // Reset UI state
if (statusEl) {
statusEl.textContent = 'β Cancel Failed';
}
if (actionsEl) {
actionsEl.innerHTML = `Γ `;
}
}
}
// ===============================
// LEGACY CANCEL SYSTEM (OLD)
// ===============================
async function cancelTrackDownload(playlistId, trackIndex) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${trackIndex}"]`);
if (!row) return;
// Prevent double cancellation
if (row.dataset.locallyCancelled === 'true') {
return; // Already cancelled locally
}
const taskId = row.dataset.taskId;
if (!taskId) {
showToast('Task not started yet, cannot cancel.', 'warning');
return;
}
// UI update for immediate feedback - mark as cancelled FIRST to prevent race conditions
row.dataset.locallyCancelled = 'true';
document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = 'π« Cancelling...';
document.getElementById(`actions-${playlistId}-${trackIndex}`).innerHTML = '-';
try {
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) {
// Update final UI state after successful cancellation
document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = 'π« Cancelled';
showToast('Download cancelled and added to wishlist.', 'info');
} else {
throw new Error(data.error);
}
} catch (error) {
// Reset UI state if cancellation failed
row.dataset.locallyCancelled = 'false';
document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = 'β Cancel Failed';
showToast(`Could not cancel task: ${error.message}`, 'error');
}
}
// Find and REPLACE the old startPlaylistSyncFromModal function
async function startPlaylistSync(playlistId, syncModeOverride = null) {
const startTime = Date.now();
// Sync mode: prefer explicit override (e.g. from automation/discover code paths
// that don't render the modal selector), else read the per-playlist
// rendered next to the Sync button, else default 'replace' to preserve
// historical behavior for any caller that hasn't been updated yet.
let syncMode = syncModeOverride;
if (!syncMode) {
const modeSelect = document.getElementById(`sync-mode-${playlistId}`);
// Empty value = "use the Settings default" β send nothing so the
// backend falls back to playlist_sync.mode (#792). Don't hardcode
// 'replace' here or it shadows the global setting.
syncMode = (modeSelect && modeSelect.value) || '';
}
if (syncMode && !['replace', 'reconcile', 'append'].includes(syncMode)) {
syncMode = '';
}
console.log(`π [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId} (mode: ${syncMode || 'default(setting)'})`);
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
if (!playlist) {
console.error(`β Could not find playlist data for ID: ${playlistId}`);
showToast('Could not find playlist data.', 'error');
return;
}
console.log(`β
Found playlist: ${playlist.name} with ${playlist.track_count || 'unknown'} tracks`);
// Check if already syncing to prevent duplicate syncs
if (activeSyncPollers[playlistId]) {
showToast('Sync already in progress for this playlist', 'warning');
return;
}
// Update button state immediately for user feedback
const syncBtn = document.getElementById(`sync-btn-${playlistId}`);
if (syncBtn) {
syncBtn.disabled = true;
syncBtn.textContent = 'β³ Syncing...';
}
// Ensure we have the full track list before starting
const playlistMeta = spotifyPlaylists.find(p => p.id === playlistId);
let tracks = playlistTrackCache[playlistId];
const cacheStale = typeof playlistTrackCacheIsStale === 'function'
&& playlistTrackCacheIsStale(playlistId, playlistMeta);
if (!tracks || cacheStale) {
const trackFetchStart = Date.now();
console.log(`π [${new Date().toTimeString().split(' ')[0]}] ${cacheStale ? 'Cache stale' : 'Cache miss'} - fetching tracks for playlist ${playlistId}`);
try {
if (cacheStale && typeof invalidatePlaylistTrackCache === 'function') {
invalidatePlaylistTrackCache(playlistId);
}
if (playlistId.startsWith('deezer_arl_') && typeof fetchAndCacheDeezerArlPlaylistTracks === 'function') {
const deezerId = playlistId.replace('deezer_arl_', '');
const fullPlaylist = await fetchAndCacheDeezerArlPlaylistTracks(playlistId, deezerId);
tracks = fullPlaylist.tracks;
} else if (typeof fetchAndCacheSpotifyPlaylistTracks === 'function' && !playlistId.startsWith('deezer_arl_')) {
const fullPlaylist = await fetchAndCacheSpotifyPlaylistTracks(playlistId);
tracks = fullPlaylist.tracks;
} else {
const fetchUrl = playlistId.startsWith('deezer_arl_')
? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}`
: `/api/spotify/playlist/${playlistId}`;
const response = await fetch(fetchUrl);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
tracks = fullPlaylist.tracks;
playlistTrackCache[playlistId] = tracks;
}
const trackFetchTime = Date.now() - trackFetchStart;
console.log(`β
[${new Date().toTimeString().split(' ')[0]}] Fetched and cached ${tracks.length} tracks (took ${trackFetchTime}ms)`);
} catch (error) {
console.error(`β Failed to fetch tracks:`, error);
showToast(`Failed to fetch tracks for sync: ${error.message}`, 'error');
return;
}
} else {
console.log(`β
[${new Date().toTimeString().split(' ')[0]}] Using cached tracks: ${tracks.length} tracks`);
}
// DON'T close the modal - let it show live progress like the GUI
try {
const syncStartTime = Date.now();
console.log(`π [${new Date().toTimeString().split(' ')[0]}] Making API call to /api/sync/start with ${tracks.length} tracks`);
const response = await fetch('/api/sync/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_id: playlist.id,
playlist_name: playlist.name,
tracks: tracks, // Send the full track list
image_url: playlist.image_url || '',
sync_mode: syncMode
})
});
const syncRequestTime = Date.now() - syncStartTime;
console.log(`π‘ [${new Date().toTimeString().split(' ')[0]}] API response status: ${response.status} (took ${syncRequestTime}ms)`);
const data = await response.json();
console.log(`π‘ [${new Date().toTimeString().split(' ')[0]}] API response data:`, data);
if (!data.success) throw new Error(data.error);
const totalTime = Date.now() - startTime;
console.log(`β
[${new Date().toTimeString().split(' ')[0]}] Sync started successfully for "${playlist.name}" (total time: ${totalTime}ms)`);
showToast(`Sync started for "${playlist.name}"`, 'success');
// Show initial sync state in modal if open
const modal = document.getElementById('playlist-details-modal') || document.getElementById('deezer-arl-playlist-details-modal');
if (modal && modal.style.display !== 'none') {
const statusDisplay = document.getElementById(`modal-sync-status-${playlist.id}`);
if (statusDisplay) {
statusDisplay.style.display = 'flex';
console.log(`π [${new Date().toTimeString().split(' ')[0]}] Showing modal sync status for ${playlist.id}`);
}
}
updateCardToSyncing(playlist.id, 0); // Initial state
startSyncPolling(playlist.id);
} catch (error) {
console.error(`β Failed to start sync:`, error);
showToast(`Failed to start sync: ${error.message}`, 'error');
updateCardToDefault(playlist.id);
}
}
// Add these new helper functions to script.js
function startSyncPolling(playlistId) {
// Clear any existing poller for this playlist
if (activeSyncPollers[playlistId]) {
clearInterval(activeSyncPollers[playlistId]);
}
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('sync:subscribe', { playlist_ids: [playlistId] });
_syncProgressCallbacks[playlistId] = (data) => {
if (data.status === 'syncing') {
const progress = data.progress;
updateCardToSyncing(playlistId, progress.progress, progress);
updateModalSyncProgress(playlistId, progress);
} else if (data.status === 'finished' || data.status === 'error' || data.status === 'cancelled') {
stopSyncPolling(playlistId);
updateCardToDefault(playlistId, data);
closePlaylistDetailsModal();
}
};
}
// Start a new poller that checks every 2 seconds
console.log(`π Starting sync polling for playlist: ${playlistId}`);
activeSyncPollers[playlistId] = setInterval(async () => {
// Always poll β no dedicated WebSocket events for discovery progress
try {
console.log(`π Polling sync status for: ${playlistId}`);
const response = await fetch(`/api/sync/status/${playlistId}`);
const state = await response.json();
console.log(`π Poll response:`, state);
if (state.status === 'syncing') {
const progress = state.progress;
console.log(`π Sync progress:`, progress);
console.log(` π Progress values: ${progress.progress}% | Total: ${progress.total_tracks} | Matched: ${progress.matched_tracks} | Failed: ${progress.failed_tracks}`);
console.log(` π Current step: "${progress.current_step}" | Current track: "${progress.current_track}"`);
// Use the actual progress percentage from the sync service
updateCardToSyncing(playlistId, progress.progress, progress);
// Also update the modal if it's open
updateModalSyncProgress(playlistId, progress);
} else if (state.status === 'finished' || state.status === 'error' || state.status === 'cancelled') {
console.log(`π Sync completed with status: ${state.status}`);
stopSyncPolling(playlistId);
updateCardToDefault(playlistId, state);
// Also update the modal if it's open
closePlaylistDetailsModal(); closeDeezerArlPlaylistDetailsModal(); // Close modal on completion/error
}
} catch (error) {
console.error(`β Error polling sync status for ${playlistId}:`, error);
stopSyncPolling(playlistId);
updateCardToDefault(playlistId, { status: 'error', error: 'Polling failed' });
}
}, 2000); // Poll every 2 seconds
updateRefreshButtonState();
}
function stopSyncPolling(playlistId) {
if (activeSyncPollers[playlistId]) {
clearInterval(activeSyncPollers[playlistId]);
delete activeSyncPollers[playlistId];
}
// Phase 5: Unsubscribe and clean up callback
if (_syncProgressCallbacks[playlistId]) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] });
delete _syncProgressCallbacks[playlistId];
}
updateRefreshButtonState();
}
// Sync sidebar visibility helpers
function showSyncSidebar() {
const sidebar = document.querySelector('.sync-sidebar');
const contentArea = document.querySelector('.sync-content-area');
if (sidebar && contentArea && window.innerWidth > 1300) {
sidebar.style.display = '';
contentArea.style.gridTemplateColumns = '2.5fr 0.75fr';
}
}
function hideSyncSidebar() {
const sidebar = document.querySelector('.sync-sidebar');
const contentArea = document.querySelector('.sync-content-area');
if (sidebar && contentArea) {
sidebar.style.display = 'none';
contentArea.style.gridTemplateColumns = '1fr';
}
}
// Sequential Sync Functions
function startSequentialSync() {
// Initialize manager if needed
if (!sequentialSyncManager) {
sequentialSyncManager = new SequentialSyncManager();
}
// Check if already running - if so, cancel
if (sequentialSyncManager.isRunning) {
sequentialSyncManager.cancel();
return;
}
// Validate selection
if (selectedPlaylists.size === 0) {
showToast('No playlists selected for sync', 'error');
return;
}
// Get playlist order from DOM to maintain display order
const playlistCards = document.querySelectorAll('.playlist-card');
const orderedPlaylistIds = [];
playlistCards.forEach(card => {
const playlistId = card.dataset.playlistId;
if (selectedPlaylists.has(playlistId)) {
orderedPlaylistIds.push(playlistId);
}
});
console.log(`π Starting sequential sync for ${orderedPlaylistIds.length} playlists`);
// Show sidebar for sync progress
showSyncSidebar();
// Start sequential sync
sequentialSyncManager.start(orderedPlaylistIds);
// Disable playlist selection during sync
disablePlaylistSelection(true);
}
function disablePlaylistSelection(disabled) {
const checkboxes = document.querySelectorAll('.playlist-checkbox');
checkboxes.forEach(checkbox => {
checkbox.disabled = disabled;
});
}
function hasActiveOperations() {
const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0;
// Only check non-wishlist download processes for sync page refresh button
const hasActiveDownloads = Object.entries(activeDownloadProcesses)
.filter(([playlistId, process]) => playlistId !== 'wishlist') // Exclude wishlist
.some(([_, process]) => process.status === 'running');
const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning;
return hasActiveSyncs || hasActiveDownloads || hasSequentialSync;
}
function updateRefreshButtonState() {
const refreshBtn = document.getElementById('spotify-refresh-btn');
if (!refreshBtn) return;
if (hasActiveOperations()) {
refreshBtn.disabled = true;
// Provide context-specific text
const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0;
const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning;
if (hasActiveSyncs || hasSequentialSync) {
refreshBtn.textContent = 'π Syncing...';
} else {
refreshBtn.textContent = 'π₯ Downloading...';
}
} else {
refreshBtn.disabled = false;
refreshBtn.textContent = 'π Refresh';
}
}
function updateCardToSyncing(playlistId, percent, progress = null) {
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
if (!card) return;
const progressBar = card.querySelector('.sync-progress-indicator');
progressBar.style.display = 'block';
let progressText = 'Starting...';
let actualPercent = percent || 0;
if (progress) {
// Create detailed progress text like the GUI
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const currentStep = progress.current_step || 'Processing';
// Calculate actual progress as processed/total, not just successful/total
if (total > 0) {
const processed = matched + failed;
actualPercent = Math.round((processed / total) * 100);
progressText = `${currentStep}: ${processed}/${total} (${matched} matched, ${failed} failed)`;
} else {
progressText = currentStep;
}
// If there's a current track being processed, show it
if (progress.current_track) {
progressText += ` - ${progress.current_track}`;
}
}
// Build live status counter HTML (same as modal)
let statusCounterHTML = '';
if (progress && progress.total_tracks > 0) {
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const processed = matched + failed;
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
statusCounterHTML = `
βͺ ${total}
/
β ${matched}
/
β ${failed}
(${percentage}%)
`;
}
progressBar.innerHTML = `
${statusCounterHTML}
${progressText}
`;
}
function updateCardToDefault(playlistId, finalState = null) {
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
if (!card) return;
const progressBar = card.querySelector('.sync-progress-indicator');
progressBar.style.display = 'none';
progressBar.innerHTML = '';
const statusEl = card.querySelector('.playlist-card-status');
if (finalState) {
if (finalState.status === 'finished') {
statusEl.textContent = `Synced: Just now`;
statusEl.className = 'playlist-card-status status-synced';
// Check if any tracks were added to wishlist
const wishlistCount = finalState.progress?.wishlist_added_count || finalState.result?.wishlist_added_count || 0;
const unmatchedTracks = finalState.progress?.unmatched_tracks || finalState.result?.unmatched_tracks || [];
const playlistName = card.querySelector('.playlist-card-name').textContent;
if (wishlistCount > 0 && unmatchedTracks.length > 0) {
const trackList = unmatchedTracks.map(t => `${t.artist} - ${t.name}`).join(', ');
showToast(`Sync complete for "${playlistName}". ${wishlistCount} not found in library: ${trackList}`, 'warning');
} else if (wishlistCount > 0) {
showToast(`Sync complete for "${playlistName}". Added ${wishlistCount} missing track${wishlistCount > 1 ? 's' : ''} to wishlist.`, 'success');
} else {
showToast(`Sync complete for "${playlistName}"`, 'success');
}
} else {
statusEl.textContent = `Sync Failed`;
statusEl.className = 'playlist-card-status status-needs-sync'; // Or a new error class
showToast(`Sync failed: ${finalState.error || 'Unknown error'}`, 'error');
}
}
}
// Update the modal's sync progress display (matches GUI functionality)
function updateModalSyncProgress(playlistId, progress) {
const modal = document.getElementById('playlist-details-modal') || document.getElementById('deezer-arl-playlist-details-modal');
if (modal && modal.style.display !== 'none') {
console.log(`π Updating modal sync progress for ${playlistId}:`, progress);
// Show sync status display
const statusDisplay = document.getElementById(`modal-sync-status-${playlistId}`);
if (statusDisplay) {
statusDisplay.style.display = 'flex';
// Update counters (matching GUI exactly)
const totalEl = document.getElementById(`modal-total-${playlistId}`);
const matchedEl = document.getElementById(`modal-matched-${playlistId}`);
const failedEl = document.getElementById(`modal-failed-${playlistId}`);
const percentageEl = document.getElementById(`modal-percentage-${playlistId}`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
// Calculate percentage like GUI
if (total > 0) {
const processed = matched + failed;
const percentage = Math.round((processed / total) * 100);
if (percentageEl) percentageEl.textContent = percentage;
}
console.log(`π Modal updated: βͺ ${total} / β ${matched} / β ${failed} (${Math.round((matched + failed) / total * 100)}%)`);
} else {
console.warn(`β Modal sync status display not found for ${playlistId}`);
}
} else {
console.log(`π Modal not open for ${playlistId}, skipping update`);
}
}
// ββ Basic-search source picker ββββββββββββββββββββββββββββββββββββββββββββββ
// Tracks which download source the user has selected in the chip row. Null
// means "use the orchestrator's default" (same as pre-redesign behaviour).
let _bsActiveSource = null;
async function initBasicSearchSources() {
const row = document.getElementById('bs-source-row');
if (!row) return;
try {
const resp = await fetch('/api/search/sources');
if (!resp.ok) return;
const { mode, sources } = await resp.json();
if (!sources || !sources.length) return;
row.innerHTML = '';
const isSingle = mode !== 'hybrid' || sources.length < 2;
sources.forEach((src, i) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bs-source-chip' + (i === 0 ? ' active' : '');
btn.dataset.source = src.name;
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
btn.setAttribute('title', src.display_name);
btn.innerHTML = `${_escBsHtml(src.display_name)} `;
if (isSingle) {
// Non-interactive: single source, just a label.
btn.classList.add('single');
btn.disabled = true;
} else {
btn.addEventListener('click', () => {
row.querySelectorAll('.bs-source-chip').forEach(c => {
c.classList.remove('active');
c.setAttribute('aria-selected', 'false');
});
btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
_bsActiveSource = src.name;
// Re-run last search with new source if results already showing.
const query = document.getElementById('downloads-search-input')?.value?.trim();
if (query && window.currentSearchResults?.length) {
performDownloadsSearch();
}
});
}
row.appendChild(btn);
});
// Default active source = first in chain.
_bsActiveSource = isSingle ? null : sources[0]?.name ?? null;
} catch (_err) {
// Non-fatal β search still works without the picker.
}
}
function _escBsHtml(str) {
return String(str || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
// Raw download-source file search (basic search tab).
async function performDownloadsSearch() {
const query = document.getElementById('downloads-search-input').value.trim();
if (!query) {
showToast('Please enter a search term', 'error');
return;
}
// --- UI Element References ---
const searchInput = document.getElementById('downloads-search-input');
const searchButton = document.getElementById('downloads-search-btn');
const cancelButton = document.getElementById('downloads-cancel-btn');
const statusText = document.getElementById('search-status-text');
const spinner = document.querySelector('.spinner-animation');
const dots = document.querySelector('.dots-animation');
// --- Start a new AbortController for this search ---
searchAbortController = new AbortController();
try {
// --- 1. Update UI to "Searching" State ---
searchInput.disabled = true;
searchButton.disabled = true;
cancelButton.classList.remove('hidden');
spinner.classList.remove('hidden');
dots.classList.remove('hidden');
statusText.textContent = `Searching for '${query}'...`;
displayDownloadsResults([]); // Clear previous results
// --- 2. Perform the Fetch Request ---
// Source param routes the search to a specific download source in
// hybrid mode. Omitted in single-source mode (backend falls through
// to orchestrator.search() which targets the configured source).
const body = { query };
if (_bsActiveSource) body.source = _bsActiveSource;
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: searchAbortController.signal // Link fetch to the AbortController
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const results = data.results || [];
allSearchResults = results;
resetFilters();
applyFiltersAndSort();
// --- 3. Update UI with Success State ---
if (results.length === 0) {
statusText.textContent = `No results found for '${query}'`;
showToast('No results found', 'error');
} else {
const filtersEl = document.getElementById('filters-container');
if (filtersEl) filtersEl.classList.remove('hidden');
// Count albums and singles like the GUI app
let totalAlbums = 0;
let totalTracks = 0;
results.forEach(result => {
if (result.result_type === 'album') {
totalAlbums++;
} else {
totalTracks++;
}
});
statusText.textContent = `β¨ Found ${results.length} results β’ ${totalAlbums} albums, ${totalTracks} singles`;
showToast(`Found ${results.length} results`, 'success');
}
} catch (error) {
// --- 4. Handle Errors, Including Cancellation ---
if (error.name === 'AbortError') {
// This specific error is thrown when the user clicks "Cancel"
statusText.textContent = 'Search was cancelled.';
showToast('Search cancelled', 'info');
displayDownloadsResults([]); // Clear any partial results
} else {
console.error('Search failed:', error);
statusText.textContent = `Search failed: ${error.message}`;
showToast('Search failed', 'error');
}
} finally {
// --- 5. Clean Up UI Regardless of Outcome ---
searchInput.disabled = false;
searchButton.disabled = false;
cancelButton.classList.add('hidden');
spinner.classList.add('hidden');
dots.classList.add('hidden');
searchAbortController = null; // Clear the controller
}
}
function displayDownloadsResults(results) {
const resultsArea = document.getElementById('search-results-area');
if (!resultsArea) return;
if (!results.length) {
resultsArea.innerHTML = '';
return;
}
let html = '';
results.forEach((result, index) => {
const isAlbum = result.result_type === 'album';
if (isAlbum) {
const trackCount = result.tracks ? result.tracks.length : 0;
const totalSize = result.total_size ? `${(result.total_size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
// Generate individual track items
let trackListHtml = '';
if (result.tracks && result.tracks.length > 0) {
// Detect disc boundaries from track number resets for multi-disc albums
let currentDisc = 1;
let lastTrackNum = 0;
let discBreaks = new Set();
result.tracks.forEach((track, trackIndex) => {
const tn = track.track_number || 0;
if (trackIndex > 0 && tn > 0 && tn <= lastTrackNum) {
currentDisc++;
discBreaks.add(trackIndex);
}
if (tn > 0) lastTrackNum = tn;
});
const isMultiDisc = discBreaks.size > 0;
if (isMultiDisc) {
trackListHtml += `Disc 1
`;
}
let discNum = 1;
result.tracks.forEach((track, trackIndex) => {
if (discBreaks.has(trackIndex)) {
discNum++;
trackListHtml += `Disc ${discNum}
`;
}
const trackSize = track.size ? `${(track.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
const trackBitrate = track.bitrate ? `${track.bitrate}kbps` : '';
trackListHtml += `
${escapeHtml(track.title || `Track ${trackIndex + 1}`)}
${track.track_number ? `${track.track_number}. ` : ''}${escapeHtml(track.artist || result.artist || 'Unknown Artist')} β’ ${trackSize} β’ ${escapeHtml(track.quality || 'Unknown')} ${trackBitrate}
Stream βΆ
Download β¬
Matched Download π―
`;
});
}
html += `
`;
} else {
const sizeText = result.size ? `${(result.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
const bitrateText = result.bitrate ? `${result.bitrate}kbps` : '';
html += `
π΅
${escapeHtml(result.title || 'Unknown Title')}
by ${escapeHtml(result.artist || 'Unknown Artist')}
${sizeText} β’ ${escapeHtml(result.quality || 'Unknown')} ${bitrateText}
Shared by ${escapeHtml(result.username || 'Unknown')}
Stream βΆ
Download β¬
Matched Downloadπ―
`;
}
});
resultsArea.innerHTML = html;
// Store results globally for download functions
window.currentSearchResults = results;
}
async function downloadTrack(index) {
const results = window.currentSearchResults;
if (!results || !results[index]) return;
const track = results[index];
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(track)
});
const data = await response.json();
if (data.success) {
showToast(`Download started: ${track.title}`, 'success');
} else {
showToast(`Download failed: ${data.error}`, 'error');
}
} catch (error) {
console.error('Download error:', error);
showToast('Failed to start download', 'error');
}
}
async function downloadAlbum(index) {
const results = window.currentSearchResults;
if (!results || !results[index]) return;
const album = results[index];
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(album)
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
} else {
showToast(`Album download failed: ${data.error}`, 'error');
}
} catch (error) {
console.error('Album download error:', error);
showToast('Failed to start album download', 'error');
}
}
// Matched download functions
function matchedDownloadTrack(index) {
const results = window.currentSearchResults;
if (!results || !results[index]) return;
const track = results[index];
console.log('π― Starting matched download for single track:', track);
// Open matching modal for single track
openMatchingModal(track, false, null);
}
function matchedDownloadAlbum(index) {
const results = window.currentSearchResults;
if (!results || !results[index]) return;
const album = results[index];
console.log('π― Starting matched download for album:', album);
// Open matching modal for album download
openMatchingModal(album, true, album);
}
function matchedDownloadAlbumTrack(albumIndex, trackIndex) {
const results = window.currentSearchResults;
if (!results || !results[albumIndex]) return;
const album = results[albumIndex];
if (!album.tracks || !album.tracks[trackIndex]) return;
const track = album.tracks[trackIndex];
// Ensure track has necessary properties from parent album
track.username = album.username;
track.artist = track.artist || album.artist;
track.album = album.album_title || album.title;
console.log('π― Starting matched download for album track:', track);
// Open matching modal for single track (from album context)
openMatchingModal(track, false, null);
}
function toggleAlbumExpansion(albumIndex) {
const albumCard = document.querySelector(`[data-album-index="${albumIndex}"]`);
if (!albumCard) return;
const trackList = albumCard.querySelector('.album-track-list');
const indicator = albumCard.querySelector('.album-expand-indicator');
if (trackList.style.display === 'none' || !trackList.style.display) {
// Expand
trackList.style.display = 'block';
indicator.textContent = 'βΌ';
albumCard.classList.add('expanded');
} else {
// Collapse
trackList.style.display = 'none';
indicator.textContent = 'βΆ';
albumCard.classList.remove('expanded');
}
}
async function downloadAlbumTrack(albumIndex, trackIndex) {
const results = window.currentSearchResults;
if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) return;
const track = results[albumIndex].tracks[trackIndex];
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...track,
result_type: 'track'
})
});
const data = await response.json();
if (data.success) {
showToast(`Download started: ${track.title}`, 'success');
} else {
showToast(`Track download failed: ${data.error}`, 'error');
}
} catch (error) {
console.error('Track download error:', error);
showToast('Failed to start track download', 'error');
}
}
// ===============================
// STREAMING WRAPPER FUNCTIONS
// ===============================
async function streamTrack(index) {
// Stream a single track from search results
try {
console.log(`π΅ streamTrack called with index: ${index}`);
console.log(`π΅ window.currentSearchResults:`, window.currentSearchResults);
if (!window.currentSearchResults || !window.currentSearchResults[index]) {
console.error(`β No search results or invalid index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`);
showToast('Track not found', 'error');
return;
}
const result = window.currentSearchResults[index];
console.log(`π΅ Streaming track:`, result);
// Check for unsupported formats before streaming (streaming sources use encoded filenames, skip check)
const isStreamingSource = result.username === 'youtube' || result.username === 'tidal' || result.username === 'qobuz' || result.username === 'hifi';
if (!isStreamingSource && result.filename) {
const format = getFileExtension(result.filename);
console.log(`π΅ [STREAM CHECK] File: ${result.filename}, Extension: ${format}`);
const isSupported = isAudioFormatSupported(result.filename);
console.log(`π΅ [STREAM CHECK] Format ${format} supported: ${isSupported}`);
if (!isSupported) {
showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error');
return;
}
}
await startStream(result);
} catch (error) {
console.error('Track streaming error:', error);
showToast('Failed to start track stream', 'error');
}
}
async function streamAlbumTrack(albumIndex, trackIndex) {
// Stream a specific track from an album
try {
console.log(`π΅ streamAlbumTrack called with albumIndex: ${albumIndex}, trackIndex: ${trackIndex}`);
console.log(`π΅ window.currentSearchResults:`, window.currentSearchResults);
if (!window.currentSearchResults || !window.currentSearchResults[albumIndex]) {
console.error(`β No search results or invalid album index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`);
showToast('Album not found', 'error');
return;
}
const album = window.currentSearchResults[albumIndex];
console.log(`π΅ Album data:`, album);
// Surgical Fix: Handle YouTube/Tidal results which are "flat" (no tracks array)
if (album.username === 'youtube' || album.username === 'tidal' || album.username === 'qobuz' || album.username === 'hifi') {
// For YouTube/Tidal results, the "album" is actually the track itself
const track = album;
const trackData = {
...track,
username: track.username,
filename: track.filename,
artist: track.artist,
album: track.title, // Use title as album name for player
title: track.title
};
console.log(`π΅ Streaming YouTube track directly:`, trackData);
await startStream(trackData);
return;
}
if (!album.tracks || !album.tracks[trackIndex]) {
console.error(`β No tracks in album or invalid track index. Tracks length: ${album.tracks ? album.tracks.length : 'undefined'}`);
showToast('Track not found in album', 'error');
return;
}
const track = album.tracks[trackIndex];
console.log(`π΅ Streaming album track:`, track);
// Ensure album tracks have required fields
const trackData = {
...track,
username: track.username || album.username,
filename: track.filename || track.path,
artist: track.artist || album.artist,
album: track.album || album.title || album.album
};
console.log(`π΅ Enhanced track data:`, trackData);
// Check for unsupported formats before streaming (streaming sources use encoded filenames, skip check)
const isStreamingSource2 = trackData.username === 'youtube' || trackData.username === 'tidal' || trackData.username === 'qobuz' || trackData.username === 'hifi';
if (!isStreamingSource2 && trackData.filename && !isAudioFormatSupported(trackData.filename)) {
const format = getFileExtension(trackData.filename);
showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error');
return;
}
await startStream(trackData);
} catch (error) {
console.error('Album track streaming error:', error);
showToast('Failed to start track stream', 'error');
}
}
async function loadArtistsData() {
try {
const response = await fetch(API.artists);
const data = await response.json();
const artistsGrid = document.getElementById('artists-grid');
if (data.artists && data.artists.length) {
artistsGrid.innerHTML = data.artists.map(artist => `
${artist.image ?
`
` :
'
π΅
'
}
${escapeHtml(artist.name)}
${artist.album_count || 0} albums
`).join('');
} else {
artistsGrid.innerHTML = 'No artists found
';
}
} catch (error) {
console.error('Error loading artists data:', error);
document.getElementById('artists-grid').innerHTML = 'Error loading artists
';
}
}
// ===============================
// UTILITY FUNCTIONS
// ===============================
function showLoadingOverlay(message = 'Loading...') {
const overlay = document.getElementById('loading-overlay');
const messageElement = overlay.querySelector('.loading-message');
messageElement.textContent = message;
overlay.classList.remove('hidden');
}
function hideLoadingOverlay() {
document.getElementById('loading-overlay').classList.add('hidden');
}
// ==================================================================================
// NOTIFICATION SYSTEM β Compact toasts + bell button + notification history panel
// ==================================================================================
const _notifState = {
history: [],
unreadCount: 0,
panelOpen: false,
currentToast: null,
toastTimer: null,
maxHistory: 50,
};
const _recentToastKeys = new Map();
const _notifIcons = { success: 'β', error: 'β', warning: 'β ', info: 'βΉ' };
function showToast(message, type = 'success', helpSection = null) {
const toastKey = `${type}:${message}`;
const now = Date.now();
// Deduplication β suppress identical toasts within 5 seconds
if (_recentToastKeys.has(toastKey) && now - _recentToastKeys.get(toastKey) < 5000) return;
_recentToastKeys.set(toastKey, now);
for (const [k, t] of _recentToastKeys) { if (now - t > 10000) _recentToastKeys.delete(k); }
// Add to notification history
const entry = { id: now + Math.random(), message, type, helpSection, timestamp: now, read: false };
_notifState.history.unshift(entry);
if (_notifState.history.length > _notifState.maxHistory) _notifState.history.pop();
_notifState.unreadCount++;
_updateNotifBadge();
// Show compact toast β dismiss current if showing
const container = document.getElementById('toast-container');
if (!container) return;
if (_notifState.currentToast && container.contains(_notifState.currentToast)) {
_notifState.currentToast.classList.add('toast-exit');
const old = _notifState.currentToast;
setTimeout(() => { if (container.contains(old)) container.removeChild(old); }, 200);
}
if (_notifState.toastTimer) clearTimeout(_notifState.toastTimer);
const icon = _notifIcons[type] || 'βΉ';
const toast = document.createElement('div');
toast.className = `toast-compact toast-${type}`;
toast.innerHTML = `${icon} ${_escToast(message)} `;
if (helpSection) {
const link = document.createElement('span');
link.className = 'toast-compact-link';
link.textContent = 'Learn more β';
link.onclick = e => { e.stopPropagation(); if (typeof navigateToDocsSection === 'function') navigateToDocsSection(helpSection); };
toast.appendChild(link);
}
toast.onclick = () => { toast.classList.add('toast-exit'); setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 200); };
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('toast-enter'));
_notifState.currentToast = toast;
_notifState.toastTimer = setTimeout(() => {
if (container.contains(toast)) {
toast.classList.add('toast-exit');
setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 300);
}
_notifState.currentToast = null;
}, helpSection ? 5000 : 3500);
}
function _escToast(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function _escAttr(s) { return _escToast(s).replace(/'/g, "\\'").replace(/\n/g, ' ').replace(/\r/g, ''); }
function _updateNotifBadge() {
const badge = document.getElementById('notif-bell-badge');
if (badge) {
badge.textContent = _notifState.unreadCount > 99 ? '99+' : _notifState.unreadCount;
badge.style.display = _notifState.unreadCount > 0 ? '' : 'none';
}
}
function toggleNotifPanel() {
if (_notifState.panelOpen) {
_closeNotifPanel();
} else {
_openNotifPanel();
}
}
function _openNotifPanel() {
_closeNotifPanel(); // Remove existing
_notifState.panelOpen = true;
_notifState.unreadCount = 0;
_notifState.history.forEach(e => e.read = true);
_updateNotifBadge();
const btn = document.getElementById('notif-bell-btn');
const panel = document.createElement('div');
panel.id = 'notif-panel';
panel.className = 'notif-panel';
const entries = _notifState.history;
panel.innerHTML = `
${entries.length === 0 ? '
No notifications yet
' :
entries.map(e => {
const icon = _notifIcons[e.type] || 'βΉ';
const ago = _notifTimeAgo(e.timestamp);
const unreadDot = e.read ? '' : '
';
const learnMore = e.helpSection ? `
Learn more β ` : '';
return `
${unreadDot}
${icon}
${_escToast(e.message)}
${ago}${learnMore}
`;
}).join('')}
`;
document.body.appendChild(panel);
// Position above the bell button
if (btn) {
const rect = btn.getBoundingClientRect();
panel.style.right = (window.innerWidth - rect.right) + 'px';
panel.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
}
requestAnimationFrame(() => panel.classList.add('visible'));
// Close on outside click
setTimeout(() => {
const closeHandler = e => {
if (!panel.contains(e.target) && e.target.id !== 'notif-bell-btn') {
_closeNotifPanel();
document.removeEventListener('click', closeHandler);
}
};
document.addEventListener('click', closeHandler);
}, 100);
}
function _closeNotifPanel() {
_notifState.panelOpen = false;
const panel = document.getElementById('notif-panel');
if (panel) {
panel.classList.remove('visible');
setTimeout(() => panel.remove(), 200);
}
}
function _clearNotifHistory() {
_notifState.history = [];
_notifState.unreadCount = 0;
_updateNotifBadge();
_closeNotifPanel();
}
function _notifTimeAgo(ts) {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 5) return 'just now';
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
// ==================================================================================
// Music video download handler β defined at top level so both enhanced and global search can use it
function _downloadMusicVideo(cardEl, video) {
if (cardEl.classList.contains('downloading') || cardEl.classList.contains('completed')) return;
cardEl.classList.add('downloading');
cardEl.onclick = null;
const playBtn = cardEl.querySelector('.enh-video-play');
const progressRing = cardEl.querySelector('.enh-video-progress-ring');
const progressBar = cardEl.querySelector('.enh-video-progress-bar');
const doneIcon = cardEl.querySelector('.enh-video-done');
const errorIcon = cardEl.querySelector('.enh-video-error');
if (playBtn) playBtn.classList.add('hidden');
if (progressRing) progressRing.classList.remove('hidden');
fetch('/api/music-video/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ video_id: video.video_id, url: video.url, title: video.title, channel: video.channel }),
}).then(res => {
if (!res.ok) throw new Error('Download request failed');
const circumference = 97.4;
const pollInterval = setInterval(async () => {
try {
const statusRes = await fetch(`/api/music-video/status/${video.video_id}`);
const status = await statusRes.json();
if (progressBar && status.progress > 0) {
progressBar.style.strokeDashoffset = circumference - (status.progress / 100) * circumference;
}
if (status.status === 'completed') {
clearInterval(pollInterval);
cardEl.classList.remove('downloading');
cardEl.classList.add('completed');
if (progressRing) progressRing.classList.add('hidden');
if (doneIcon) doneIcon.classList.remove('hidden');
} else if (status.status === 'error') {
clearInterval(pollInterval);
cardEl.classList.remove('downloading');
cardEl.classList.add('errored');
if (progressRing) progressRing.classList.add('hidden');
if (errorIcon) errorIcon.classList.remove('hidden');
cardEl.onclick = () => _downloadMusicVideo(cardEl, video);
}
} catch (e) { }
}, 500);
}).catch(e => {
cardEl.classList.remove('downloading');
if (progressRing) progressRing.classList.add('hidden');
if (playBtn) playBtn.classList.remove('hidden');
if (errorIcon) errorIcon.classList.remove('hidden');
cardEl.onclick = () => _downloadMusicVideo(cardEl, video);
});
}
// Global search video click β decodes base64 video data and delegates to _downloadMusicVideo
function _gsClickVideo(cardEl) {
try {
const encoded = cardEl.dataset.video;
const video = JSON.parse(decodeURIComponent(escape(atob(encoded))));
_downloadMusicVideo(cardEl, video);
} catch (e) {
console.error('Failed to parse video data:', e);
}
}
// GLOBAL SEARCH BAR β Spotlight-style search from anywhere
// ==================================================================================
// Popover-only state. Query/source/cache/config all live in `_gsController`
// (shared with the Search page via createSearchController in shared-helpers.js).
const _gsState = {
active: false,
_lastInteraction: 0,
debounceTimer: null,
};
// Shared source-picker controller β built on DOM-ready in `_doInit`.
let _gsController = null;
(function initGlobalSearch() {
// Defer init until DOM is ready
const _doInit = () => {
const bar = document.getElementById('gsearch-bar');
const input = document.getElementById('gsearch-input');
const results = document.getElementById('gsearch-results');
if (!input || !bar || !results) return;
// Build the stable results-panel structure up front so the controller
// has a sourceRow element to render into on its first _notify().
results.innerHTML = `
`;
_gsController = createSearchController({
sourceRowElement: document.getElementById('gsearch-source-row'),
iconClassPrefix: 'gsearch',
onStateChange: _gsRenderFromState,
onSoulseekSelected: (query) => _gsNavigateToSearchPage(query, 'soulseek'),
onUnconfiguredClick: (src) => {
_gsDeactivate();
openSettingsForSource(src);
},
});
bar.addEventListener('click', () => input.focus());
input.addEventListener('focus', () => {
bar.classList.add('active');
const aura = document.getElementById('gsearch-aura');
if (aura) aura.classList.add('active');
_gsState.active = true;
const shortcut = document.getElementById('gsearch-shortcut');
if (shortcut) shortcut.style.display = 'none';
// Always redraw on focus so the source icon row is current
// (cache dots, active state, etc.). init() is a no-op after the
// first call β safe to invoke on every focus.
_gsController.init().then(() => _gsRenderFromState(_gsController.state));
});
// No blur handler β closing is handled by click-outside and Escape only
// This prevents tab switching and result clicks from closing the panel
const clearBtn = document.getElementById('gsearch-clear');
input.addEventListener('input', () => {
const q = input.value.trim();
if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none';
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
if (q.length < 2) { _gsHideResults(); return; }
// 600ms (was 300) β coalesce a name being typed into one search
// instead of one external-API search per letter (#751). Enter still
// fires immediately via the keydown handler.
_gsState.debounceTimer = setTimeout(() => _gsController.submitQuery(q), 600);
});
if (clearBtn) {
clearBtn.addEventListener('click', e => {
e.stopPropagation();
input.value = '';
clearBtn.style.display = 'none';
// Drop cache so the next search starts clean, but don't
// auto-fire a fetch for an empty query.
if (_gsController) {
_gsController.state.query = '';
_gsController.state.sources = {};
_gsController.state.fallbacks = {};
_gsController.state.loadingSources = new Set();
_gsController.renderSourceRow();
}
_gsHideResults();
input.focus();
});
}
input.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
const q = input.value.trim();
if (q.length >= 2) _gsController.submitQuery(q);
} else if (e.key === 'Escape') {
_gsDeactivate();
input.blur();
}
});
// Keyboard shortcuts
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); input.focus(); return; }
if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); input.focus(); }
});
// Click outside to close β uses delayed check because tab clicks replace DOM
document.addEventListener('click', e => {
if (!_gsState.active) return;
// Skip if click was recent interaction with search system (within 100ms of a switch)
if (_gsState._lastInteraction && Date.now() - _gsState._lastInteraction < 200) return;
setTimeout(() => {
if (!_gsState.active) return;
const freshBar = document.getElementById('gsearch-bar');
const freshResults = document.getElementById('gsearch-results');
const target = e.target;
if (freshBar?.contains(target) || freshResults?.contains(target)) return;
// The media player (mini bar + expanded now-playing modal)
// floats above the page. Clicking it β e.g. opening the full
// modal from the mini player, or anything inside that modal β
// must NOT tear down the global search results (#732).
if (target.closest && target.closest('#media-player, #np-modal-overlay')) return;
_gsDeactivate();
}, 100);
});
// Collapse on sidebar navigation + hide on downloads page
document.addEventListener('click', e => {
if (e.target.closest('.sidebar-link, .nav-item, .back-btn')) {
if (_gsState.active) _gsDeactivate();
// Check after navigation which page we're on
setTimeout(_gsUpdateVisibility, 200);
}
});
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => { _doInit(); _gsUpdateVisibility(); });
else { _doInit(); setTimeout(_gsUpdateVisibility, 500); }
})();
// Init basic-search source chip row once the DOM is ready.
(function _bsInit() {
const run = () => {
if (typeof initBasicSearchSources === 'function') initBasicSearchSources();
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run);
else run();
})();
function _gsUpdateVisibility() {
const bar = document.getElementById('gsearch-bar');
const aura = document.getElementById('gsearch-aura');
if (!bar) return;
// Hide on pages where global search doesn't belong.
const _gsHidePages = new Set(['search', 'downloads', 'settings', 'help', 'issues', 'import']);
const onHidePage = typeof currentPage !== 'undefined' && _gsHidePages.has(currentPage);
bar.style.display = onHidePage ? 'none' : '';
if (aura) aura.classList.toggle('hidden', onHidePage);
if (onHidePage && _gsState.active) _gsDeactivate();
}
function _gsDeactivate() {
const bar = document.getElementById('gsearch-bar');
const aura = document.getElementById('gsearch-aura');
const shortcut = document.getElementById('gsearch-shortcut');
if (bar) bar.classList.remove('active');
if (aura) aura.classList.remove('active');
if (shortcut) shortcut.style.display = '';
_gsState.active = false;
_gsHideResults();
}
function _gsHideResults() {
const r = document.getElementById('gsearch-results');
if (r) r.classList.remove('visible');
}
function _gsShowResults() {
const r = document.getElementById('gsearch-results');
if (r && r.innerHTML.trim()) r.classList.add('visible');
}
function _gsNavigateToSearchPage(query, src) {
_gsDeactivate();
if (typeof navigateToPage !== 'function') return;
navigateToPage('search');
// After the page mounts, mirror the query into whichever input drives the
// requested source. Soulseek goes through the basic-search file flow, not
// the enhanced metadata flow β without this branch the Search page would
// run /api/enhanced-search instead of /api/search and the user would get
// metadata results when they clicked the Soulseek icon.
setTimeout(() => {
if (src === 'soulseek') {
const basicInput = document.getElementById('downloads-search-input');
if (basicInput && query) basicInput.value = query;
// Sync the Search page controller's state.query to the widget's
// query BEFORE clicking the Soulseek icon. Otherwise the icon
// click fires onSoulseekSelected(state.query) where state.query
// is whatever the user last typed on /search (often stale), and
// the callback would overwrite basicInput.value with that stale
// value before running performDownloadsSearch.
if (typeof _searchPageController !== 'undefined' && _searchPageController) {
_searchPageController.state.query = query || '';
}
const soulseekIcon = document.querySelector('#enh-source-row [data-source="soulseek"]');
if (soulseekIcon) {
soulseekIcon.click();
return;
}
// Fallback: controller hasn't initialized yet (slow /api/settings
// fetches at first /search visit). Run the search directly + swap
// sections so the user still gets results. Icon row will catch up
// visually on the next render.
const basicSection = document.getElementById('basic-search-section');
const enhancedSection = document.getElementById('enhanced-search-section');
if (basicSection) basicSection.classList.add('active');
if (enhancedSection) enhancedSection.classList.remove('active');
if (basicInput && basicInput.value && typeof performDownloadsSearch === 'function') {
performDownloadsSearch();
}
return;
}
const input = document.getElementById('enhanced-search-input');
if (input && query) {
input.value = query;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}, 300);
}
// Re-render the results body + fallback banner whenever the controller's
// state changes (cache hit, fetch settle, query reset). The icon row itself
// is rendered by the controller into `#gsearch-source-row`.
function _gsRenderFromState(state) {
const results = document.getElementById('gsearch-results');
const body = document.getElementById('gsearch-body');
if (!results || !body) return;
// Fallback banner β independent of body content.
const banner = document.getElementById('gsearch-fallback-banner');
const activeSrc = state.activeSource;
const actual = state.fallbacks[activeSrc];
if (banner) {
if (actual && actual !== activeSrc) {
const clicked = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc;
const served = (SOURCE_LABELS[actual] || {}).text || actual;
banner.textContent = `${clicked} unavailable β showing ${served}.`;
banner.classList.remove('hidden');
} else {
banner.classList.add('hidden');
}
}
// Soulseek has its own dedicated handler (navigate to /search); there's
// nothing to render in the popover.
if (activeSrc === 'soulseek') return;
const cached = state.sources[activeSrc];
const isLoading = state.loadingSources.has(activeSrc);
const query = state.query;
// No query yet β prompt.
if (!query) {
body.innerHTML = 'Type to searchβ¦
';
results.classList.add('visible');
return;
}
// In-flight, nothing cached yet β loading state.
if (isLoading && !cached) {
const info = SOURCE_LABELS[activeSrc];
body.innerHTML = `
Searching ${_escToast((info && info.text) || activeSrc)}...
`;
results.classList.add('visible');
return;
}
// No cache, not loading β source switch before fetch fired (e.g. empty query).
if (!cached) {
body.innerHTML = 'Click the source above to search.
';
results.classList.add('visible');
return;
}
// Music Videos β video grid instead of regular sections.
if (activeSrc === 'youtube_videos') {
const videos = cached.videos || [];
let h = ``;
h += '';
if (videos.length === 0) {
h += `
No music videos found for "${_escToast(query)}"
`;
} else {
h += '';
h += '
';
h += videos.map(v => {
const dur = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : '';
const views = v.view_count >= 1000000 ? `${(v.view_count / 1000000).toFixed(1)}M` : v.view_count >= 1000 ? `${(v.view_count / 1000).toFixed(1)}K` : (v.view_count || '');
const vJson = btoa(unescape(encodeURIComponent(JSON.stringify(v))));
return `
βΆ
β
β
${dur ? `
${dur} ` : ''}
${_escToast(v.title)}
${_escToast(v.channel)}${views ? ` Β· ${views} views` : ''}
`;
}).join('');
h += '
';
}
h += '
';
body.innerHTML = h;
results.classList.add('visible');
return;
}
// Standard metadata source β library + artists + albums + singles + tracks.
const dbArtists = cached.db_artists || [];
const artists = cached.artists || [];
const allAlbums = cached.albums || [];
const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation');
const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep');
const tracks = cached.tracks || [];
const total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length;
if (total === 0) {
body.innerHTML = `No results for "${_escToast(query)}"Try different keywords or check spelling
`;
results.classList.add('visible');
return;
}
const srcLabel = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc || '';
let h = '';
h += ``;
h += '';
if (dbArtists.length) {
h += '
';
}
if (artists.length) {
h += `
';
}
if (albums.length) {
h += `
`;
h += albums.map(a => {
const ar = a.artist || (a.artists ? a.artists.join(', ') : '');
const yr = a.release_date ? a.release_date.substring(0, 4) : '';
const img = (a.image_url || '').replace(/'/g, "\\'");
return `
${a.image_url ? `
` : 'πΏ'}
${_escToast(a.name)}
${_escToast(ar)}${yr ? ` Β· ${yr}` : ''}
`;
}).join('');
h += '
';
}
if (singles.length) {
h += `
`;
h += singles.map(a => {
const ar = a.artist || (a.artists ? a.artists.join(', ') : '');
const img = (a.image_url || '').replace(/'/g, "\\'");
return `
${a.image_url ? `
` : 'πΆ'}
${_escToast(a.name)}
${_escToast(ar)}
`;
}).join('');
h += '
';
}
if (tracks.length) {
h += `
`;
h += tracks.map(t => {
const ar = t.artist || (t.artists ? t.artists.join(', ') : '');
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
return `
${t.image_url ? `
` : 'π΅'}
${_escToast(t.name)}
${_escToast(ar)}${t.album ? ` Β· ${_escToast(t.album)}` : ''}
${dur}
βΆ `;
}).join('');
h += '
';
}
h += '
';
body.innerHTML = h;
results.classList.add('visible');
// Lazy load artist images for sources that don't provide them (iTunes/Deezer).
_gsLazyLoadArtistImages();
// Library ownership check β adds "In Library" badges + swaps play buttons.
// Idempotent enough to run on every render with a cache hit; the old flow
// also fired it on both cache-hit and fetch-settle.
setTimeout(() => _gsLibraryCheck(), 200);
}
async function _gsLazyLoadArtistImages() {
const grid = document.getElementById('gsearch-artists-grid');
if (!grid) return;
const cards = grid.querySelectorAll('[data-needs-image="true"]');
if (cards.length === 0) return;
const activeSrc = (_gsController && _gsController.state.activeSource) || 'spotify';
for (const card of cards) {
const artistId = card.dataset.artistId;
if (!artistId) continue;
try {
// Pass the artist name so MusicBrainz lookups (which have no
// artist art) can resolve the image by name on a fallback source.
const params = new URLSearchParams({ source: activeSrc });
if (card.dataset.artistName) params.set('name', card.dataset.artistName);
const res = await fetch(`/api/artist/${artistId}/image?${params}`);
const data = await res.json();
if (data.success && data.image_url) {
const artDiv = card.querySelector('.gsearch-item-art');
if (artDiv) artDiv.innerHTML = ` `;
card.removeAttribute('data-needs-image');
}
} catch (e) { /* ignore */ }
}
}
async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) {
_gsDeactivate();
// Same flow as handleEnhancedSearchAlbumClick β fetch album, open download modal
showLoadingOverlay('Loading album...');
try {
const params = new URLSearchParams({ name: albumName, artist: artistName });
if (source && source !== 'spotify') params.set('source', source);
const response = await fetch(`/api/spotify/album/${albumId}?${params}`);
if (!response.ok) throw new Error(`Failed to load album: ${response.status}`);
const albumData = await response.json();
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
hideLoadingOverlay();
showToast(`No tracks available for "${albumName}"`, 'warning');
return;
}
const enrichedTracks = albumData.tracks.map(t => ({
...t,
album: { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks }
}));
const virtualPlaylistId = `enhanced_search_album_${albumId}`;
const firstArtist = (albumData.artists || [])[0] || {};
const artistObj = { id: firstArtist.id || '', name: firstArtist.name || artistName, source: source || '' };
const albumObj = { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks, artists: albumData.artists || [{ name: artistName }] };
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, `[${artistName}] ${albumData.name}`, enrichedTracks, albumObj, artistObj, false);
// Register download bubble (same pattern as enhanced search)
registerSearchDownload(
{
id: albumData.id,
name: albumData.name,
artist: artistName,
image_url: albumData.images?.[0]?.url || imageUrl || null,
images: albumData.images || []
},
'album',
virtualPlaylistId,
artistName
);
} catch (e) {
hideLoadingOverlay();
showToast('Failed to load album: ' + e.message, 'error');
}
}
async function _gsClickTrack(artistName, trackName, albumName, trackId, imageUrl, durationMs) {
_gsDeactivate();
// Build enriched track + open download modal directly (same as enhanced search)
const virtualPlaylistId = `gsearch_track_${trackId || (artistName + '_' + trackName).replace(/\s/g, '_')}`;
const enrichedTrack = {
id: trackId || '',
name: trackName,
artists: [artistName],
album: { name: albumName || '', id: null, album_type: 'single', images: imageUrl ? [{ url: imageUrl }] : [], total_tracks: 1 },
duration_ms: durationMs || 0,
image_url: imageUrl || '',
};
const albumObject = {
name: albumName || '', id: null, album_type: 'single',
images: imageUrl ? [{ url: imageUrl }] : [],
artists: [{ name: artistName }], total_tracks: 1,
};
const artistObject = { id: null, name: artistName };
const playlistName = `${artistName} - ${trackName}`;
try {
showLoadingOverlay('Loading track...');
await openDownloadMissingModalForArtistAlbum(
virtualPlaylistId, playlistName, [enrichedTrack], albumObject, artistObject, false
);
// Register download bubble (same pattern as enhanced search)
registerSearchDownload(
{
id: trackId || '',
name: trackName,
artist: artistName,
image_url: imageUrl || null,
images: imageUrl ? [{ url: imageUrl }] : []
},
'track',
virtualPlaylistId,
artistName
);
} catch (e) {
console.error('Error opening track download:', e);
// Fallback: navigate to the unified Search page
navigateToPage('search');
setTimeout(() => {
const input = document.getElementById('enhanced-search-input');
if (input) { input.value = `${artistName} ${trackName}`.trim(); input.dispatchEvent(new Event('input')); }
}, 300);
} finally {
hideLoadingOverlay();
}
}
async function _gsPlayTrack(trackName, artistName, albumName) {
try {
showToast('Searching for stream...', 'info');
const res = await fetch('/api/enhanced-search/stream-track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_name: trackName, artist_name: artistName, album_name: albumName })
});
const data = await res.json();
if (data.success && data.result) {
if (typeof startStream === 'function') {
startStream(data.result);
} else {
showToast('Streaming not available', 'error');
}
} else {
showToast(data.error || 'No stream found', 'error');
}
} catch (e) {
showToast('Stream failed: ' + e.message, 'error');
}
}
// Async library check for global search results β adds badges + swaps play buttons
async function _gsLibraryCheck() {
try {
if (!_gsController) return;
const src = _gsController.state.sources[_gsController.state.activeSource] || {};
const allAlbums = src.albums || [];
const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation');
const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep');
const tracks = src.tracks || [];
if (!allAlbums.length && !tracks.length) return;
const res = await fetch('/api/enhanced-search/library-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
albums: allAlbums.map(a => ({ name: a.name, artist: a.artist || (a.artists ? a.artists.join(', ') : '') })),
tracks: tracks.map(t => ({ name: t.name, artist: t.artist || (t.artists ? t.artists.join(', ') : '') })),
})
});
const checkData = await res.json();
// Add "In Library" badges to albums β match by index against allAlbums order
const albumResults = checkData.albums || [];
let albumIdx = 0;
// Albums section
document.querySelectorAll('#gsearch-results .gsearch-results-body').forEach(body => {
// Find all gsearch-item elements and tag ones that are albums
const sections = body.querySelectorAll('.gsearch-section-header');
sections.forEach(header => {
const text = header.textContent;
const isAlbumSection = text.includes('Albums') || text.includes('Singles');
if (!isAlbumSection) return;
const grid = header.nextElementSibling;
if (!grid) return;
const items = grid.querySelectorAll('.gsearch-item');
items.forEach(item => {
if (albumIdx < albumResults.length && albumResults[albumIdx]) {
if (!item.querySelector('.gsearch-item-badge')) {
const badge = document.createElement('span');
badge.className = 'gsearch-item-badge';
badge.textContent = 'In Library';
item.appendChild(badge);
}
}
albumIdx++;
});
});
});
// Tag tracks + swap play buttons for library playback
const trackResults = checkData.tracks || [];
const trackEls = document.querySelectorAll('#gsearch-results .gsearch-track');
trackEls.forEach((el, i) => {
const tr = trackResults[i];
if (tr && tr.in_library) {
// Add badge
if (!el.querySelector('.gsearch-item-badge')) {
const badge = document.createElement('span');
badge.className = 'gsearch-item-badge';
badge.textContent = 'In Library';
badge.style.marginRight = '4px';
el.querySelector('.gsearch-track-dur')?.before(badge);
}
// Swap play button to library playback
if (tr.file_path) {
const playBtn = el.querySelector('.gsearch-play-btn');
if (playBtn) {
const newBtn = playBtn.cloneNode(true);
newBtn.removeAttribute('onclick');
newBtn.title = 'Play from library';
newBtn.style.background = 'rgba(76,175,80,0.15)';
newBtn.style.color = '#4caf50';
newBtn.addEventListener('click', e => {
e.stopPropagation();
playLibraryTrack(
{ id: tr.track_id, title: tr.title, file_path: tr.file_path, _stats_image: tr.album_thumb_url || null },
tr.album_title || '',
tr.artist_name || ''
);
});
playBtn.replaceWith(newBtn);
}
}
} else if (tr && tr.in_wishlist) {
if (!el.querySelector('.gsearch-item-badge')) {
const badge = document.createElement('span');
badge.className = 'gsearch-item-badge gsearch-wishlist-badge';
badge.textContent = 'In Wishlist';
badge.style.marginRight = '4px';
el.querySelector('.gsearch-track-dur')?.before(badge);
}
}
});
} catch (e) {
// Non-critical
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Escape a value for safe use inside a single-quoted JS string literal
* within a double-quoted HTML attribute (e.g. onclick="fn('${val}')").
*
* Layer 1 (JS): escape \ and ' so the JS string parses correctly.
* Layer 2 (HTML): escape &, ", <, > so the HTML attribute parses correctly.
* The browser applies these in reverse: HTML-decode first, then JS-execute.
*/
function escapeForInlineJs(str) {
if (str == null) return '';
return String(str)
.replace(/\\/g, '\\\\') // JS: literal backslash
.replace(/'/g, "\\'") // JS: single quote
.replace(/&/g, '&') // HTML: ampersand
.replace(/"/g, '"') // HTML: double quote
.replace(//g, '>'); // HTML: greater-than
}
function formatArtists(artists) {
if (!artists || !Array.isArray(artists)) {
return 'Unknown Artist';
}
// Handle both string arrays and object arrays with 'name' property
const artistNames = artists.map(artist => {
let artistName;
if (typeof artist === 'string') {
artistName = artist;
} else if (artist && typeof artist === 'object' && artist.name) {
artistName = artist.name;
} else {
artistName = 'Unknown Artist';
}
// Clean featured artists from the name
return cleanArtistName(artistName);
});
return artistNames.join(', ') || 'Unknown Artist';
}
async function checkForUpdates() {
try {
const res = await fetch('/api/update-check');
if (!res.ok) return;
const data = await res.json();
const btn = document.querySelector('.version-button');
if (!btn) return;
if (data.update_available) {
const dismissed = localStorage.getItem('soulsync-update-dismissed');
if (dismissed !== data.latest_sha) {
// Add glow class
btn.classList.add('update-available');
// Add UPDATE badge if not already present
if (!btn.querySelector('.update-badge')) {
const badge = document.createElement('span');
badge.className = 'update-badge';
badge.textContent = 'UPDATE';
btn.appendChild(badge);
}
// Show toast on first detection (not if already notified this session)
const notified = sessionStorage.getItem('soulsync-update-notified');
if (notified !== data.latest_sha) {
sessionStorage.setItem('soulsync-update-notified', data.latest_sha);
showToast(data.is_docker
? 'A new SoulSync update has been pushed to the repo β Docker image will be updated soon!'
: 'A new SoulSync update is available!', 'info');
}
}
} else {
btn.classList.remove('update-available');
const badge = btn.querySelector('.update-badge');
if (badge) badge.remove();
}
} catch (e) {
console.debug('Update check failed:', e);
}
}
async function showVersionInfo() {
// Check update status before dismissing so we can pass it to the modal
let updateInfo = null;
const btn = document.querySelector('.version-button');
const hadUpdate = btn && btn.classList.contains('update-available');
// Dismiss update glow when user opens the modal
if (hadUpdate) {
btn.classList.remove('update-available');
const badge = btn.querySelector('.update-badge');
if (badge) badge.remove();
try {
const updateRes = await fetch('/api/update-check');
if (updateRes.ok) {
updateInfo = await updateRes.json();
if (updateInfo.latest_sha) {
localStorage.setItem('soulsync-update-dismissed', updateInfo.latest_sha);
}
}
} catch (e) { /* ignore */ }
}
// Build version data straight from helper.js β single source of truth.
// No backend round-trip; the changelog content is shipped in the same
// bundle the browser already loaded.
const version = (typeof _getCurrentVersion === 'function')
? _getCurrentVersion()
: (btn ? btn.textContent.trim().replace('v', '') : '');
const sections = (typeof VERSION_MODAL_SECTIONS !== 'undefined')
? VERSION_MODAL_SECTIONS
: [];
const versionData = {
version,
title: "What's New in SoulSync",
subtitle: version ? `Version ${version} β Latest Changes` : 'Latest Changes',
sections,
};
try {
populateVersionModal(versionData, hadUpdate ? updateInfo : null);
const modalOverlay = document.getElementById('version-modal-overlay');
if (modalOverlay) modalOverlay.classList.remove('hidden');
} catch (error) {
console.error('Error showing version info:', error);
showToast('Failed to load version information', 'error');
}
}
function closeVersionModal() {
const modalOverlay = document.getElementById('version-modal-overlay');
modalOverlay.classList.add('hidden');
console.log('Version modal closed');
}
function populateVersionModal(versionData, updateInfo) {
const container = document.getElementById('version-content-container');
if (!container) {
console.error('Version content container not found');
return;
}
// Update header with dynamic data
const titleElement = document.querySelector('.version-modal-title');
const subtitleElement = document.querySelector('.version-modal-subtitle');
if (titleElement) titleElement.textContent = versionData.title;
if (subtitleElement) subtitleElement.textContent = versionData.subtitle;
// Clear existing content
container.innerHTML = '';
// Show update banner if an update was available when modal was opened
if (updateInfo && updateInfo.update_available) {
const banner = document.createElement('div');
banner.className = 'version-update-banner';
const isDocker = updateInfo.is_docker;
banner.innerHTML = `
⬆
${isDocker ? 'Repo update detected' : 'New update available'}
${isDocker
? 'A new update has been pushed to the repo. The Docker image will be updated soon β no action needed yet.'
: `Your version: ${updateInfo.current_sha || 'unknown'} → Latest: ${updateInfo.latest_sha || 'unknown'}`}
`;
container.appendChild(banner);
}
// Create sections
versionData.sections.forEach(section => {
const sectionDiv = document.createElement('div');
sectionDiv.className = 'version-feature-section';
// Section title
const titleDiv = document.createElement('div');
titleDiv.className = 'version-section-title';
titleDiv.textContent = section.title;
sectionDiv.appendChild(titleDiv);
// Section description
const descDiv = document.createElement('div');
descDiv.className = 'version-section-description';
descDiv.textContent = section.description;
sectionDiv.appendChild(descDiv);
// Features list
const featuresList = document.createElement('ul');
featuresList.className = 'version-feature-list';
section.features.forEach(feature => {
const featureItem = document.createElement('li');
featureItem.className = 'version-feature-item';
featureItem.textContent = feature;
featuresList.appendChild(featureItem);
});
sectionDiv.appendChild(featuresList);
// Usage note (if present)
if (section.usage_note) {
const usageDiv = document.createElement('div');
usageDiv.className = 'version-usage-note';
usageDiv.textContent = `π‘ ${section.usage_note}`;
sectionDiv.appendChild(usageDiv);
}
container.appendChild(sectionDiv);
});
console.log('Version modal content populated');
}
// ===============================
// ADDITIONAL STYLES FOR SEARCH RESULTS
// ===============================
// Add dynamic styles for search results (since they're created dynamically)
const additionalStyles = `
`;
// Inject additional styles
document.head.insertAdjacentHTML('beforeend', additionalStyles);
// ============================================================================