soulsync/webui/static/downloads.js
BoulderBadgeDad 8ee59c7453 Blocklist Phase 2b: gate manual downloads with a "download anyway?" confirm
Closes the last acquisition gap — user-initiated downloads. A blocklist isn't
a censor, so search + discography stay fully visible; instead the download
ACTION is gated, visibly and overridably:

- Download modal (start-missing-process): an up-front check — if the WHOLE
  album or artist being downloaded is blocklisted, return 409 {blocked:true}
  with the entity, before starting a batch. The modal shows "X is blocklisted
  — download anyway?" and re-POSTs with ignore_blocklist:true on confirm
  (threaded onto the batch so the Phase 2a per-track filter skips it).
  Scattered single-track bans still fall through to the 2a filter quietly.
- Manual /api/download (search-result download): source-file-centric, so it
  matches the blocked ARTIST by name; same 409 + confirm + override. search.js
  now sends artist/title so the guard has something to match.
- Precedence confirmed: force-download overrides "already owned", NOT a ban
  (the 2a filter runs on the force-expanded missing list).

Frontend: shared confirmBlockedDownload() helper; modal + search callers
handle the blocked response and retry with the override.

Tests: manual download blocked-by-name / unrelated-allowed / override-passes,
and the modal up-front 409 for a blocked album. 8 blocklist API tests pass.
2026-06-07 16:15:23 -07:00

6722 lines
300 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = `
<button class="wing-it-dropdown-item" data-action="download">
<span class="wing-it-dropdown-icon">⬇️</span>
<span class="wing-it-dropdown-label">Download</span>
<span class="wing-it-dropdown-hint">Raw names</span>
</button>
<button class="wing-it-dropdown-item" data-action="sync">
<span class="wing-it-dropdown-icon">🔄</span>
<span class="wing-it-dropdown-label">Sync to Server</span>
<span class="wing-it-dropdown-hint">Best-effort</span>
</button>
`;
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 = `
<div class="smart-delete-modal">
<div class="smart-delete-header">
<h3>⚡ Wing It</h3>
<button class="smart-delete-close">&times;</button>
</div>
<p class="smart-delete-desc">${trackCount} track${trackCount !== 1 ? 's' : ''} from ${source}. No metadata discovery — uses raw names. Failed tracks won't be added to wishlist.</p>
<div class="smart-delete-options">
<button class="smart-delete-option" data-choice="download">
<div class="smart-delete-option-icon">⬇️</div>
<div class="smart-delete-option-info">
<div class="smart-delete-option-title" style="color:#4caf50">Download</div>
<div class="smart-delete-option-desc">Search and download each track using raw names.</div>
</div>
</button>
<button class="smart-delete-option" data-choice="sync">
<div class="smart-delete-option-icon">🔄</div>
<div class="smart-delete-option-info">
<div class="smart-delete-option-title" style="color:#64b5f6">Sync to Server</div>
<div class="smart-delete-option-desc">Mirror playlist and sync to your media server. Best-effort matching.</div>
</div>
</button>
</div>
</div>
`;
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 = `
<div class="download-missing-modal-content" data-context="playlist">
<div class="download-missing-modal-header">
${generateDownloadModalHeroSection(heroContext)}
</div>
<div class="download-missing-modal-body">
<div class="download-progress-section">
<div class="progress-item">
<div class="progress-label">
🔍 Library Analysis
<span id="analysis-progress-text-${virtualPlaylistId}">Ready to start</span>
</div>
<div class="progress-bar">
<div class="progress-fill analysis" id="analysis-progress-fill-${virtualPlaylistId}"></div>
</div>
</div>
<div class="progress-item">
<div class="progress-label">
⏬ Downloads
<span id="download-progress-text-${virtualPlaylistId}">Waiting for analysis</span>
</div>
<div class="progress-bar">
<div class="progress-fill download" id="download-progress-fill-${virtualPlaylistId}"></div>
</div>
</div>
</div>
<div class="download-tracks-section">
<div class="download-tracks-header">
<h3 class="download-tracks-title">📋 Track Analysis & Download Status</h3>
<span class="track-selection-count" id="track-selection-count-${virtualPlaylistId}">${spotifyTracks.length} / ${spotifyTracks.length} tracks selected</span>
</div>
<div class="download-tracks-table-container">
<table class="download-tracks-table">
<thead>
<tr>
<th class="track-select-header">
<input type="checkbox" class="track-select-all"
id="select-all-${virtualPlaylistId}" checked
onchange="toggleAllTrackSelections('${virtualPlaylistId}', this.checked)">
</th>
<th>#</th>
<th>Track</th>
<th>Artist</th>
<th>Duration</th>
<th>Library Match</th>
<th>Download Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="download-tracks-tbody-${virtualPlaylistId}">
${spotifyTracks.map((track, index) => `
<tr data-track-index="${index}">
<td class="track-select-cell">
<input type="checkbox" class="track-select-cb"
data-track-index="${index}" checked
onchange="updateTrackSelectionCount('${virtualPlaylistId}')">
</td>
<td class="track-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
<td class="track-download-status" id="download-${virtualPlaylistId}-${index}">-</td>
<td class="track-actions" id="actions-${virtualPlaylistId}-${index}">-</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
</div>
<div class="download-missing-modal-footer">
<div class="download-phase-controls">
<div class="force-download-toggle-container" style="margin-bottom: 0px; display: flex; flex-direction: column; gap: 8px; align-items: flex-start;">
<label class="force-download-toggle">
<input type="checkbox" id="force-download-all-${virtualPlaylistId}">
<span>Force Download All</span>
</label>
${isDiscoverAlbum ? '' : `<label class="force-download-toggle">
<input type="checkbox" id="playlist-folder-mode-${virtualPlaylistId}">
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span>
</label>`}
</div>
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
Begin Analysis
</button>
<button class="download-control-btn" id="add-to-wishlist-btn-${virtualPlaylistId}" onclick="addModalTracksToWishlist('${virtualPlaylistId}')" style="background-color: #9333ea; color: white;">
Add to Wishlist
</button>
<button class="download-control-btn danger" id="cancel-all-btn-${virtualPlaylistId}" onclick="cancelAllOperations('${virtualPlaylistId}')" style="display: none;">
Cancel All
</button>
</div>
<div class="modal-close-section">
<button class="download-control-btn export" onclick="exportPlaylistAsM3U('${virtualPlaylistId}')">
📋 Export as M3U
</button>
<button class="download-control-btn secondary" onclick="closeDownloadMissingModal('${virtualPlaylistId}')">Close</button>
</div>
</div>
</div>
`;
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 `
<div class="wishlist-mosaic-fallback"></div>
<div class="wishlist-mosaic-overlay"></div>
`;
}
// 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 = '<div class="wishlist-mosaic-background">';
// 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 += `<div class="wishlist-mosaic-row-wrapper">`;
mosaicHTML += `<div class="wishlist-mosaic-row scroll-${direction}" style="--speed: ${scrollSpeed}s;">`;
// Generate tiles - duplicate 2 times for smooth infinite scroll
for (let duplicate = 0; duplicate < 2; duplicate++) {
for (let i = 0; i < shuffledCovers.length; i++) {
const coverUrl = shuffledCovers[i];
mosaicHTML += `
<div class="wishlist-mosaic-tile">
<div class="wishlist-mosaic-image" style="background-image: url('${coverUrl}');"></div>
</div>
`;
}
}
mosaicHTML += '</div>'; // Close row
mosaicHTML += '</div>'; // Close wrapper
}
mosaicHTML += '</div>';
mosaicHTML += '<div class="wishlist-mosaic-overlay"></div>'; // 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 = `
<div class="modal-container playlist-modal">
<div class="playlist-modal-header">
<div class="playlist-header-content">
<h2>🎵 Wishlist Overview</h2>
<div class="playlist-quick-info">
<span class="playlist-track-count">${total} Total Tracks</span>
<span class="playlist-owner" id="wishlist-next-auto-timer">Next Auto: ${nextCycleText}${countdownText ? ' in ' + countdownText : ''}</span>
</div>
</div>
<span class="playlist-modal-close" onclick="closeWishlistOverviewModal()">×</span>
</div>
<div class="playlist-modal-body">
<div class="wishlist-category-grid">
<!-- Albums/EPs Category -->
<div class="wishlist-category-card ${currentCycle === 'albums' ? 'next-in-queue' : ''}" data-category="albums" onclick="selectWishlistCategory('albums')">
${generateMosaicBackground(albumCovers)}
<div class="wishlist-category-content">
<div class="wishlist-category-icon">💿</div>
<div class="wishlist-category-title">Albums / EPs</div>
<div class="wishlist-category-count">${albums} tracks</div>
${currentCycle === 'albums' ? '<div class="wishlist-category-badge">Next in Queue</div>' : ''}
</div>
</div>
<!-- Singles Category -->
<div class="wishlist-category-card ${currentCycle === 'singles' ? 'next-in-queue' : ''}" data-category="singles" onclick="selectWishlistCategory('singles')">
${generateMosaicBackground(singleCovers)}
<div class="wishlist-category-content">
<div class="wishlist-category-icon">🎵</div>
<div class="wishlist-category-title">Singles</div>
<div class="wishlist-category-count">${singles} tracks</div>
${currentCycle === 'singles' ? '<div class="wishlist-category-badge">Next in Queue</div>' : ''}
</div>
</div>
</div>
<!-- Selected Category Track List (initially hidden) -->
<div id="wishlist-category-tracks" class="wishlist-category-tracks" style="display: none;">
<div class="wishlist-category-header">
<button class="wishlist-back-btn" onclick="backToCategories()">← Back</button>
<span id="wishlist-category-name" class="wishlist-category-name"></span>
<button class="wishlist-select-all-btn" id="wishlist-select-all-btn" onclick="toggleWishlistSelectAll()">Select All</button>
</div>
<div class="wishlist-batch-bar" id="wishlist-batch-bar" style="display: none;">
<span class="wishlist-batch-count" id="wishlist-batch-count">0 selected</span>
<button class="playlist-modal-btn playlist-modal-btn-secondary wishlist-batch-remove-btn"
onclick="batchRemoveFromWishlist()">
Remove Selected
</button>
</div>
<div id="wishlist-tracks-list" class="playlist-tracks-scroll">
<div class="loading-indicator">Loading tracks...</div>
</div>
</div>
</div>
<div class="playlist-modal-footer">
<div class="playlist-modal-footer-left">
<button class="playlist-modal-btn playlist-modal-btn-danger" onclick="clearEntireWishlist()">
🗑️ Clear Wishlist
</button>
<button class="playlist-modal-btn playlist-modal-btn-warning" onclick="cleanupWishlistOverview()">
🧹 Cleanup Wishlist
</button>
</div>
<div class="playlist-modal-footer-right">
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeWishlistOverviewModal()">Close</button>
<button id="wishlist-download-btn" class="playlist-modal-btn playlist-modal-btn-primary" style="display: none;" onclick="downloadSelectedCategory()">
Download Selection
</button>
</div>
</div>
</div>
`;
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');
}
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 = '<div class="loading-indicator">Loading tracks...</div>';
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 = '<div class="empty-state">No tracks in this category</div>';
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 = '<div class="wishlist-album-grid">';
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 `
<div class="wishlist-album-track wishlist-track-item">
<label class="wishlist-checkbox-wrapper">
<input type="checkbox" class="wishlist-select-cb"
data-track-id="${safeTrackId}">
<span class="wishlist-checkbox-custom"></span>
</label>
<span class="wishlist-album-track-name">${safeTrackName}</span>
<button class="wishlist-delete-btn wishlist-delete-btn-small" data-track-id="${safeTrackId}" title="Remove from wishlist">
🗑️
</button>
</div>
`;
}).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, '&#39;');
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 ? '' : '<span style="opacity: 0.3;">💿</span>';
albumsHTML += `
<div class="wishlist-album-card">
<div class="wishlist-album-header" data-album-id="${safeAlbumId}">
<label class="wishlist-checkbox-wrapper">
<input type="checkbox" class="wishlist-album-select-all-cb"
data-album-id="${safeAlbumId}">
<span class="wishlist-checkbox-custom"></span>
</label>
<div class="wishlist-album-image" style="${albumImageStyle}">${albumImageContent}</div>
<div class="wishlist-album-info">
<div class="wishlist-album-name">${safeAlbumName}</div>
<div class="wishlist-album-artist">${safeArtistName}</div>
<div class="wishlist-album-track-count">${albumData.tracks.length} track${albumData.tracks.length !== 1 ? 's' : ''}</div>
</div>
<button class="wishlist-delete-btn wishlist-delete-album-btn" data-album-id="${safeAlbumId}" title="Remove all tracks from album">
🗑️
</button>
<div class="wishlist-album-expand-icon" id="expand-icon-${albumId}">▼</div>
</div>
<div class="wishlist-album-tracks" id="tracks-${safeAlbumId}" style="display: none;">
${tracksListHTML}
</div>
</div>
`;
});
albumsHTML += '</div>';
tracksList.innerHTML = albumsHTML;
if (totalAvailable > tracks.length) {
tracksList.insertAdjacentHTML('beforeend',
`<button class="wishlist-load-more-btn" onclick="loadMoreWishlistTracks()">Load More (${tracks.length} of ${totalAvailable})</button>`);
}
_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, '&#39;');
tracksHTML += `
<div class="playlist-track-item-with-image wishlist-track-item">
<label class="wishlist-checkbox-wrapper">
<input type="checkbox" class="wishlist-select-cb"
data-track-id="${safeTrackId}">
<span class="wishlist-checkbox-custom"></span>
</label>
<div class="playlist-track-image" style="background-image: url('${safeAlbumImage}')"></div>
<div class="playlist-track-info">
<div class="playlist-track-name">${safeTrackName}</div>
<div class="playlist-track-artist">${safeArtistName}${safeAlbumName}</div>
</div>
<button class="wishlist-delete-btn" data-track-id="${safeTrackId}" title="Remove from wishlist">
🗑️
</button>
</div>
`;
});
tracksList.innerHTML = tracksHTML;
if (totalAvailable > tracks.length) {
tracksList.insertAdjacentHTML('beforeend',
`<button class="wishlist-load-more-btn" onclick="loadMoreWishlistTracks()">Load More (${tracks.length} of ${totalAvailable})</button>`);
}
_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 = `
<div class="confirmation-modal">
<div class="confirmation-modal-icon">${icon}</div>
<div class="confirmation-modal-title">${title}</div>
<div class="confirmation-modal-message">${message}</div>
<div class="confirmation-modal-buttons">
<button class="confirmation-modal-btn confirmation-modal-btn-cancel" id="confirm-cancel">Cancel</button>
<button class="confirmation-modal-btn confirmation-modal-btn-confirm" id="confirm-yes">Yes, Remove</button>
</div>
</div>
`;
// 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 = `
<div class="download-missing-modal-content" data-context="wishlist">
<div class="download-missing-modal-header">
${generateDownloadModalHeroSection(heroContext)}
</div>
<div class="download-missing-modal-body">
<div class="download-progress-section">
<div class="progress-item">
<div class="progress-label">
🔍 Library Analysis
<span id="analysis-progress-text-${playlistId}">Ready to start</span>
</div>
<div class="progress-bar">
<div class="progress-fill analysis" id="analysis-progress-fill-${playlistId}"></div>
</div>
</div>
<div class="progress-item">
<div class="progress-label">
⏬ Downloads
<span id="download-progress-text-${playlistId}">Waiting for analysis</span>
</div>
<div class="progress-bar">
<div class="progress-fill download" id="download-progress-fill-${playlistId}"></div>
</div>
</div>
</div>
<div class="download-tracks-section">
<div class="download-tracks-header">
<h3 class="download-tracks-title">📋 Track Analysis & Download Status</h3>
</div>
<div class="download-tracks-table-container">
<table class="download-tracks-table">
<thead>
<tr>
<th>#</th>
<th>Track</th>
<th>Artist</th>
<th>Library Match</th>
<th>Download Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="download-tracks-tbody-${playlistId}">
${tracks.map((track, index) => `
<tr data-track-index="${index}">
<td class="track-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${renderModalTrackPlayButton(playlistId, index)}${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
<td class="track-match-status match-checking" id="match-${playlistId}-${index}">🔍 Pending</td>
<td class="track-download-status" id="download-${playlistId}-${index}">-</td>
<td class="track-actions" id="actions-${playlistId}-${index}">-</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
</div>
<div class="download-missing-modal-footer">
<div class="download-phase-controls">
<div class="force-download-toggle-container" style="margin-bottom: 0px;">
<label class="force-download-toggle">
<input type="checkbox" id="force-download-all-${playlistId}">
<span>Force Download All</span>
</label>
</div>
<button class="download-control-btn primary" id="begin-analysis-btn-${playlistId}" onclick="startWishlistMissingTracksProcess('${playlistId}')">
Begin Analysis
</button>
<button class="download-control-btn danger" id="cancel-all-btn-${playlistId}" onclick="cancelAllOperations('${playlistId}')" style="display: none;">
Cancel All
</button>
<button class="download-control-btn secondary" id="cleanup-wishlist-btn-${playlistId}" onclick="cleanupWishlist('${playlistId}')" style="margin-left: 10px;">
🧹 Cleanup Wishlist
</button>
<button class="download-control-btn danger" id="clear-wishlist-btn-${playlistId}" onclick="clearWishlist('${playlistId}')" style="margin-left: 10px;">
🗑️ Clear Wishlist
</button>
</div>
<div class="modal-close-section">
<button class="download-control-btn secondary" onclick="closeDownloadMissingModal('${playlistId}')">Close</button>
</div>
</div>
</div>
`;
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 `<button class="modal-track-play-btn" onclick="event.stopPropagation(); playDownloadModalTrack('${escapeForInlineJs(playlistId)}', ${trackIndex})" title="Play track">&#9654;</button>`;
}
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 <tr> 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.
function _renderCandidateRow(c, index, rowClass, showSourceBadge) {
const shortFile = c.filename ? c.filename.split(/[/\\]/).pop() : '-';
const qBadge = c.quality
? `<span class="candidates-quality-badge candidates-quality-${c.quality.toLowerCase()}">${c.quality.toUpperCase()}</span>`
: '';
const sourceBadge = (showSourceBadge && c.source)
? `<span class="candidates-source-badge" title="Source">${escapeHtml(c.source)}</span> `
: '';
return `<tr class="${rowClass}">
<td class="candidates-col-index">${index + 1}</td>
<td class="candidates-col-file" title="${escapeHtml(c.filename || '')}">${sourceBadge}${escapeHtml(shortFile)}</td>
<td class="candidates-col-quality">${qBadge}${c.bitrate ? ` ${c.bitrate}kbps` : ''}</td>
<td class="candidates-col-size">${_candidatesFmtSize(c.size)}</td>
<td class="candidates-col-duration">${_candidatesFmtDur(c.duration)}</td>
<td class="candidates-col-user" title="Queue: ${c.queue_length || 0} | Slots: ${c.free_upload_slots || 0}">${escapeHtml(c.username || '-')}</td>
<td class="candidates-col-action"><button class="candidates-download-btn" data-index="${index}" title="Download this file">⬇</button></td>
</tr>`;
}
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 = `<tr><td colspan="7" style="text-align:center; color: rgba(255,255,255,0.5); padding: 30px;">
No candidates were found during search.</td></tr>`;
} 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 = ['<option value="all">All sources</option>']
.concat(availableSources.map(s =>
`<option value="${escapeHtml(s.id)}">${escapeHtml(s.label)}</option>`
))
.join('');
sourceControl = `<select class="candidates-manual-source" id="candidates-manual-source">${optionsHtml}</select>`;
} 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 = `<span class="candidates-manual-source-label">Searching ${escapeHtml(label)}</span>`;
}
const manualSearchHtml = `
<div class="candidates-manual-search">
<div class="candidates-manual-search-header">Manual search</div>
<div class="candidates-manual-search-controls">
<input type="text"
class="candidates-manual-search-input"
id="candidates-manual-search-input"
placeholder="Search for a different track..."
maxlength="200" />
${sourceControl}
<button class="candidates-manual-search-btn"
id="candidates-manual-search-btn"
disabled>Search</button>
</div>
<div class="candidates-manual-search-hint" id="candidates-manual-search-hint">Type at least 2 characters</div>
<div class="candidates-manual-search-results" id="candidates-manual-search-results"></div>
</div>`;
overlay = document.createElement('div');
overlay.id = 'candidates-modal-overlay';
overlay.className = 'candidates-modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) closeCandidatesModal(); };
overlay.innerHTML = `
<div class="candidates-modal">
<div class="candidates-modal-header">
<div>
<h2 class="candidates-modal-title">Search Results</h2>
<div class="candidates-modal-subtitle">${escapeHtml(trackName)}${escapeHtml(trackArtist)}</div>
</div>
<button class="candidates-modal-close" onclick="closeCandidatesModal()">&#x2715;</button>
</div>
<div class="candidates-modal-body">
${errorMsg ? `<div class="candidates-error-summary">${escapeHtml(errorMsg)}</div>` : ''}
${manualSearchHtml}
<div class="candidates-auto-section">
<div class="candidates-count">${candidates.length} candidate${candidates.length !== 1 ? 's' : ''} found${candidates.length > 0 ? ' but none passed filters' : ''}</div>
<div class="candidates-table-wrapper">
<table class="candidates-table">
<thead><tr>
<th>#</th><th>File</th><th>Quality</th><th>Size</th><th>Duration</th><th>User</th><th></th>
</tr></thead>
<tbody>${tableRows}</tbody>
</table>
</div>
</div>
</div>
</div>`;
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 = `
<div class="candidates-manual-search-status" id="candidates-manual-search-status">Searching...</div>
<div class="candidates-table-wrapper" style="display: none;" id="candidates-manual-table-wrapper">
<table class="candidates-table">
<thead><tr>
<th>#</th><th>File</th><th>Quality</th><th>Size</th><th>Duration</th><th>User</th><th></th>
</tr></thead>
<tbody id="candidates-manual-tbody"></tbody>
</table>
</div>`;
};
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 = `<div class="candidates-manual-search-error">${escapeHtml(msg)}</div>`;
};
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
? `<div class="candidates-manual-search-empty-note">${errors.length} source${errors.length !== 1 ? 's' : ''} failed</div>`
: '';
resultsContainer.innerHTML = `
<div class="candidates-manual-search-empty">No manual search results for "${escapeHtml(q)}"</div>
${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...'; break;
case 'downloading': statusText = `⏬ Downloading... ${Math.round(task.progress || 0)}%`; break;
case 'post_processing': statusText = '⌛ Processing...'; break;
case 'completed': statusText = '✅ Completed'; 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('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;
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 = '<span style="color: #666;">Cancelling...</span>';
} else {
// Create V2 cancel button for all active tasks
const onclickHandler = isV2Task ? 'cancelTrackDownloadV2' : 'cancelTrackDownload';
actionsEl.innerHTML = `<button class="cancel-track-btn" title="Cancel this download" onclick="${onclickHandler}('${playlistId}', ${task.track_index})">×</button>`;
}
} else if (actionsEl && task.status === 'failed' && isQuarantinedTask && task.quarantine_entry_id) {
const entryId = escapeHtml(task.quarantine_entry_id);
actionsEl.innerHTML = `<button class="approve-quarantine-inline-btn" data-entry-id="${entryId}" data-task-id="${escapeHtml(task.task_id)}" title="Approve quarantined file">Approve</button>`;
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 = '<span style="color: #666;">Cancelling...</span>';
}
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 = `<button class="cancel-track-btn" title="Cancel this download" onclick="cancelTrackDownloadV2('${playlistId}', ${trackIndex})">×</button>`;
}
}
} 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 = `<button class="cancel-track-btn" title="Cancel this download" onclick="cancelTrackDownloadV2('${playlistId}', ${trackIndex})">×</button>`;
}
}
}
// ===============================
// 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 <select>
// 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 = `
<div class="playlist-card-sync-status">
<span class="sync-stat total-tracks">♪ ${total}</span>
<span class="sync-separator">/</span>
<span class="sync-stat matched-tracks">✓ ${matched}</span>
<span class="sync-separator">/</span>
<span class="sync-stat failed-tracks">✗ ${failed}</span>
<span class="sync-stat percentage">(${percentage}%)</span>
</div>
`;
}
progressBar.innerHTML = `
${statusCounterHTML}
<div class="progress-bar-sync">
<div class="progress-fill-sync" style="width: ${actualPercent}%;"></div>
</div>
<div class="progress-text-sync">${progressText}</div>
`;
}
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 = `<span class="bs-source-label">${_escBsHtml(src.display_name)}</span>`;
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 = '<div class="search-results-placeholder"><p>No search results found.</p></div>';
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 += `<div class="disc-separator" style="padding: 6px 12px; font-weight: 600; font-size: 0.85em; color: var(--text-secondary, #aaa); border-bottom: 1px solid var(--border-color, #333); margin-bottom: 4px;">Disc 1</div>`;
}
let discNum = 1;
result.tracks.forEach((track, trackIndex) => {
if (discBreaks.has(trackIndex)) {
discNum++;
trackListHtml += `<div class="disc-separator" style="padding: 6px 12px; font-weight: 600; font-size: 0.85em; color: var(--text-secondary, #aaa); border-bottom: 1px solid var(--border-color, #333); margin: 8px 0 4px 0;">Disc ${discNum}</div>`;
}
const trackSize = track.size ? `${(track.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
const trackBitrate = track.bitrate ? `${track.bitrate}kbps` : '';
trackListHtml += `
<div class="track-item">
<div class="track-item-info">
<div class="track-item-title">${escapeHtml(track.title || `Track ${trackIndex + 1}`)}</div>
<div class="track-item-details">
${track.track_number ? `${track.track_number}. ` : ''}${escapeHtml(track.artist || result.artist || 'Unknown Artist')}${trackSize}${escapeHtml(track.quality || 'Unknown')} ${trackBitrate}
</div>
</div>
<div class="track-item-actions">
<button onclick="streamAlbumTrack(${index}, ${trackIndex})" class="track-stream-btn">Stream ▶</button>
<button onclick="downloadAlbumTrack(${index}, ${trackIndex})" class="track-download-btn">Download ⬇</button>
<button onclick="matchedDownloadAlbumTrack(${index}, ${trackIndex})" class="track-matched-btn" title="Matched Download">Matched Download 🎯</button>
</div>
</div>
`;
});
}
html += `
<div class="album-result-card" data-album-index="${index}">
<div class="album-card-header" onclick="toggleAlbumExpansion(${index})">
<div class="album-expand-indicator">▶</div>
<div class="album-icon">💿</div>
<div class="album-info">
<div class="album-title">${escapeHtml(result.album_title || result.title || 'Unknown Album')}</div>
<div class="album-artist">by ${escapeHtml(result.artist || 'Unknown Artist')}</div>
<div class="album-details">
${trackCount} tracks • ${totalSize}${escapeHtml(result.quality || 'Mixed')}
</div>
<div class="album-uploader">Shared by ${escapeHtml(result.username || 'Unknown')}</div>
</div>
<div class="album-actions" onclick="event.stopPropagation()">
<button onclick="downloadAlbum(${index})" class="album-download-btn">⬇ Download Album</button>
<button onclick="matchedDownloadAlbum(${index})" class="album-matched-btn" title="Matched Album Download">Matched Album🎯</button>
</div>
</div>
<div class="album-track-list" style="display: none;">
${trackListHtml}
</div>
</div>
`;
} else {
const sizeText = result.size ? `${(result.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
const bitrateText = result.bitrate ? `${result.bitrate}kbps` : '';
html += `
<div class="track-result-card">
<div class="track-icon">🎵</div>
<div class="track-info">
<div class="track-title">${escapeHtml(result.title || 'Unknown Title')}</div>
<div class="track-artist">by ${escapeHtml(result.artist || 'Unknown Artist')}</div>
<div class="track-details">
${sizeText}${escapeHtml(result.quality || 'Unknown')} ${bitrateText}
</div>
<div class="track-uploader">Shared by ${escapeHtml(result.username || 'Unknown')}</div>
</div>
<div class="track-actions">
<button onclick="streamTrack(${index})" class="track-stream-btn" title="Stream Track">Stream ▶</button>
<button onclick="downloadTrack(${index})" class="track-download-btn" title="Download">Download ⬇</button>
<button onclick="matchedDownloadTrack(${index})" class="track-matched-btn" title="Matched Download">Matched Download🎯</button>
</div>
</div>
`;
}
});
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 => `
<div class="artist-card">
<div class="artist-image">
${artist.image ?
`<img src="${artist.image}" alt="${escapeHtml(artist.name)}" />` :
'<div class="artist-placeholder">🎵</div>'
}
</div>
<div class="artist-info">
<div class="artist-name">${escapeHtml(artist.name)}</div>
<div class="artist-albums">${artist.album_count || 0} albums</div>
</div>
</div>
`).join('');
} else {
artistsGrid.innerHTML = '<div class="no-artists">No artists found</div>';
}
} catch (error) {
console.error('Error loading artists data:', error);
document.getElementById('artists-grid').innerHTML = '<div class="error">Error loading artists</div>';
}
}
// ===============================
// 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 = `<span class="toast-compact-icon">${icon}</span><span class="toast-compact-msg">${_escToast(message)}</span>`;
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 = `
<div class="notif-panel-header">
<span class="notif-panel-title">Notifications</span>
${entries.length > 0 ? '<button class="notif-panel-clear" onclick="_clearNotifHistory()">Clear All</button>' : ''}
</div>
<div class="notif-panel-body">
${entries.length === 0 ? '<div class="notif-panel-empty">No notifications yet</div>' :
entries.map(e => {
const icon = _notifIcons[e.type] || '';
const ago = _notifTimeAgo(e.timestamp);
const unreadDot = e.read ? '' : '<span class="notif-entry-unread"></span>';
const learnMore = e.helpSection ? `<span class="notif-entry-link" onclick="event.stopPropagation(); _closeNotifPanel(); navigateToDocsSection('${e.helpSection}')">Learn more →</span>` : '';
return `
<div class="notif-entry notif-entry-${e.type}">
${unreadDot}
<span class="notif-entry-icon notif-icon-${e.type}">${icon}</span>
<div class="notif-entry-body">
<div class="notif-entry-msg">${_escToast(e.message)}</div>
<div class="notif-entry-meta">${ago}${learnMore}</div>
</div>
</div>`;
}).join('')}
</div>
`;
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 = `
<div class="gsearch-source-row" id="gsearch-source-row"></div>
<div class="gsearch-fallback-banner hidden" id="gsearch-fallback-banner"></div>
<div id="gsearch-body"></div>
`;
_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 = '<div class="gsearch-empty">Type to search…</div>';
results.classList.add('visible');
return;
}
// In-flight, nothing cached yet — loading state.
if (isLoading && !cached) {
const info = SOURCE_LABELS[activeSrc];
body.innerHTML = `<div class="gsearch-loading"><div class="server-search-spinner"></div>Searching ${_escToast((info && info.text) || activeSrc)}...</div>`;
results.classList.add('visible');
return;
}
// No cache, not loading — source switch before fetch fired (e.g. empty query).
if (!cached) {
body.innerHTML = '<div class="gsearch-empty">Click the source above to search.</div>';
results.classList.add('visible');
return;
}
// Music Videos — video grid instead of regular sections.
if (activeSrc === 'youtube_videos') {
const videos = cached.videos || [];
let h = `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${videos.length} videos</span></div>`;
h += '<div class="gsearch-results-body">';
if (videos.length === 0) {
h += `<div class="gsearch-empty">No music videos found for "${_escToast(query)}"</div>`;
} else {
h += '<div class="gsearch-section-header">🎬 Music Videos</div>';
h += '<div class="enh-video-grid">';
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 `<div class="enh-video-card" data-video-id="${v.video_id}" data-video="${vJson}" onclick="_gsClickVideo(this)">
<div class="enh-video-thumb"><img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'"><div class="enh-video-play">▶</div>
<div class="enh-video-progress-ring hidden"><svg viewBox="0 0 36 36"><circle class="enh-video-progress-bg" cx="18" cy="18" r="15.5" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="3"/><circle class="enh-video-progress-bar" cx="18" cy="18" r="15.5" fill="none" stroke="rgb(var(--accent-rgb))" stroke-width="3" stroke-dasharray="97.4" stroke-dashoffset="97.4" stroke-linecap="round" transform="rotate(-90 18 18)"/></svg></div>
<div class="enh-video-done hidden">✓</div><div class="enh-video-error hidden">✗</div>
${dur ? `<span class="enh-video-duration">${dur}</span>` : ''}</div>
<div class="enh-video-info"><div class="enh-video-title">${_escToast(v.title)}</div><div class="enh-video-channel">${_escToast(v.channel)}${views ? ` · ${views} views` : ''}</div></div>
</div>`;
}).join('');
h += '</div>';
}
h += '</div>';
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 = `<div class="gsearch-empty">No results for "${_escToast(query)}"<br><span style="font-size:10px;opacity:0.5">Try different keywords or check spelling</span></div>`;
results.classList.add('visible');
return;
}
const srcLabel = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc || '';
let h = '';
h += `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${total} items</span></div>`;
h += '<div class="gsearch-results-body">';
if (dbArtists.length) {
h += '<div class="gsearch-section-header">📚 In Your Library</div><div class="gsearch-grid">';
h += dbArtists.map(a => `<a class="gsearch-item" href="${a.id ? buildArtistDetailPath(a.id, null) : '#'}" onclick="_gsDeactivate()" style="text-decoration:none;color:inherit;">${a.image_url ? `<div class="gsearch-item-art"><img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎤'"></div>` : '<div class="gsearch-item-art">🎤</div>'}<div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">Library</div></div></a>`).join('');
h += '</div>';
}
if (artists.length) {
h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid" id="gsearch-artists-grid">`;
h += artists.map(a => `<a class="gsearch-item" href="${a.id ? buildArtistDetailPath(a.id, activeSrc || null) : '#'}" onclick="_gsDeactivate()" ${!a.image_url ? `data-artist-id="${a.id}" data-needs-image="true" data-artist-name="${_escAttr(a.name)}"` : ''} style="text-decoration:none;color:inherit;">${a.image_url ? `<div class="gsearch-item-art"><img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎤'"></div>` : '<div class="gsearch-item-art">🎤</div>'}<div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div></div></a>`).join('');
h += '</div>';
}
if (albums.length) {
h += `<div class="gsearch-section-header">💿 Albums <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
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 `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escAttr(a.name)}', '${_escAttr(ar)}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='💿'">` : '💿'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${yr ? ` · ${yr}` : ''}</div></div></div>`;
}).join('');
h += '</div>';
}
if (singles.length) {
h += `<div class="gsearch-section-header">🎶 Singles & EPs <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
h += singles.map(a => {
const ar = a.artist || (a.artists ? a.artists.join(', ') : '');
const img = (a.image_url || '').replace(/'/g, "\\'");
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escAttr(a.name)}', '${_escAttr(ar)}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎶'">` : '🎶'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}</div></div></div>`;
}).join('');
h += '</div>';
}
if (tracks.length) {
h += `<div class="gsearch-section-header">🎵 Tracks <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-track-list">`;
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 `<div class="gsearch-track" onclick="_gsClickTrack('${_escAttr(ar)}', '${_escAttr(t.name)}', '${_escAttr(t.album || '')}', '${_escAttr(t.id || '')}', '${_escAttr(t.image_url || '')}', ${t.duration_ms || 0})"><div class="gsearch-item-art" style="width:32px;height:32px;border-radius:6px">${t.image_url ? `<img src="${t.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎵'">` : '🎵'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(t.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}</div></div><div class="gsearch-track-dur">${dur}</div><button class="gsearch-play-btn" onclick="event.stopPropagation(); _gsPlayTrack('${_escAttr(t.name)}', '${_escAttr(ar)}', '${_escAttr(t.album || '')}')" title="Stream">▶</button></div>`;
}).join('');
h += '</div>';
}
h += '</div>';
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 = `<img src="${data.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎤'">`;
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, '&amp;') // HTML: ampersand
.replace(/"/g, '&quot;') // HTML: double quote
.replace(/</g, '&lt;') // HTML: less-than
.replace(/>/g, '&gt;'); // 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 = `
<div class="version-update-banner-icon">&#x2B06;</div>
<div class="version-update-banner-text">
<strong>${isDocker ? 'Repo update detected' : 'New update available'}</strong>
<span>${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'} &rarr; Latest: ${updateInfo.latest_sha || 'unknown'}`}</span>
</div>
`;
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 = `
<style>
.search-result-item {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.search-result-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(29, 185, 84, 0.2);
}
.result-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.result-info {
flex: 1;
min-width: 0;
}
.result-title {
font-size: 14px;
font-weight: 600;
color: #ffffff;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-artist {
font-size: 12px;
color: #b3b3b3;
margin-bottom: 2px;
}
.result-album {
font-size: 11px;
color: #888888;
}
.result-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.stream-button, .download-button {
padding: 6px 12px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.stream-button {
background: rgba(var(--accent-rgb), 0.1);
color: rgb(var(--accent-light-rgb));
border: 1px solid rgba(var(--accent-rgb), 0.3);
}
.stream-button:hover {
background: rgba(29, 185, 84, 0.2);
border-color: rgba(29, 185, 84, 0.5);
}
.download-button {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.download-button:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.result-details {
display: flex;
gap: 16px;
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
}
.result-quality {
color: rgb(var(--accent-light-rgb));
font-weight: 500;
}
.no-results, .no-artists, .error {
text-align: center;
color: rgba(255, 255, 255, 0.5);
padding: 40px 20px;
font-size: 14px;
}
.artist-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
}
.artist-card:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(29, 185, 84, 0.2);
}
.artist-image {
width: 120px;
height: 120px;
margin: 0 auto 12px auto;
border-radius: 8px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
}
.artist-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: rgba(255, 255, 255, 0.3);
}
.artist-name {
font-size: 14px;
font-weight: 600;
color: #ffffff;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist-albums {
font-size: 12px;
color: #b3b3b3;
}
</style>
`;
// Inject additional styles
document.head.insertAdjacentHTML('beforeend', additionalStyles);
// ============================================================================