';
tracksList.innerHTML = albumsHTML;
if (totalAvailable > tracks.length) {
tracksList.insertAdjacentHTML('beforeend',
``);
}
_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 || '';
tracksHTML += `
${trackName}
${artistName} β’ ${albumName}
`;
});
tracksList.innerHTML = tracksHTML;
if (totalAvailable > tracks.length) {
tracksList.insertAdjacentHTML('beforeend',
``);
}
_attachWishlistDelegation(tracksList);
}
} catch (error) {
console.error('Error loading category tracks:', error);
showToast(`Failed to load tracks: ${error.message}`, 'error');
}
}
async function loadMoreWishlistTracks() {
const btn = document.querySelector('.wishlist-load-more-btn');
if (btn) { btn.textContent = 'Loading...'; btn.disabled = true; }
// Increase page size and reload
window._wlOffset = (window._wlOffset || 200) + 200;
// Override the page size for this reload
window._wlNextLimit = window._wlOffset;
selectWishlistCategory(window._wlCategory);
}
function _attachWishlistDelegation(container) {
// Single click handler for all wishlist album/track interactions
container.addEventListener('click', (e) => {
const target = e.target;
// Skip checkbox wrapper clicks β handled by change listener
if (target.closest('.wishlist-checkbox-wrapper')) return;
// Album header click (expand/collapse)
const header = target.closest('.wishlist-album-header');
if (header && !target.closest('.wishlist-delete-album-btn')) {
toggleAlbumTracks(header.dataset.albumId);
return;
}
// Album delete button
const albumDelBtn = target.closest('.wishlist-delete-album-btn');
if (albumDelBtn) {
e.stopPropagation();
removeAlbumFromWishlist(albumDelBtn.dataset.albumId, e);
return;
}
// Track delete button
const trackDelBtn = target.closest('.wishlist-delete-btn');
if (trackDelBtn && trackDelBtn.dataset.trackId) {
e.stopPropagation();
removeTrackFromWishlist(trackDelBtn.dataset.trackId, e);
return;
}
});
// Separate change handler for checkboxes (more reliable than click for inputs)
container.addEventListener('change', (e) => {
const target = e.target;
if (target.classList.contains('wishlist-album-select-all-cb')) {
toggleWishlistAlbumSelection(target.dataset.albumId, target.checked);
} else if (target.classList.contains('wishlist-select-cb')) {
updateWishlistBatchBar();
}
});
}
function backToCategories() {
_nebulaBack();
}
function toggleAlbumTracks(albumId) {
const tracksElement = document.getElementById(`tracks-${albumId}`);
const expandIcon = document.getElementById(`expand-icon-${albumId}`);
if (tracksElement.style.display === 'none') {
tracksElement.style.display = 'block';
expandIcon.textContent = 'β²';
} else {
tracksElement.style.display = 'none';
expandIcon.textContent = 'βΌ';
}
}
/**
* Get all checked wishlist track checkboxes
*/
function getCheckedWishlistTracks() {
return Array.from(document.querySelectorAll('.wishlist-select-cb:checked'));
}
/**
* Toggle select all / deselect all tracks in the current wishlist category
*/
function toggleWishlistSelectAll() {
const allCheckboxes = document.querySelectorAll('.wishlist-select-cb');
const albumCheckboxes = document.querySelectorAll('.wishlist-album-select-all-cb');
const btn = document.getElementById('wishlist-select-all-btn');
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
const newState = !allChecked;
allCheckboxes.forEach(cb => { cb.checked = newState; });
albumCheckboxes.forEach(cb => { cb.checked = newState; });
// Expand all albums when selecting all
if (newState) {
document.querySelectorAll('.wishlist-album-tracks').forEach(el => {
el.style.display = 'block';
});
document.querySelectorAll('[id^="expand-icon-"]').forEach(icon => {
icon.textContent = 'β²';
});
}
if (btn) btn.textContent = newState ? 'Deselect All' : 'Select All';
updateWishlistBatchBar();
}
/**
* Update the wishlist batch action bar based on checkbox selection
*/
function updateWishlistBatchBar() {
const checked = getCheckedWishlistTracks();
const bar = document.getElementById('wishlist-batch-bar');
const countEl = document.getElementById('wishlist-batch-count');
if (!bar || !countEl) return;
if (checked.length > 0) {
bar.style.display = 'flex';
countEl.textContent = `${checked.length} selected`;
} else {
bar.style.display = 'none';
}
// Sync the Select All button text
const btn = document.getElementById('wishlist-select-all-btn');
if (btn) {
const allCheckboxes = document.querySelectorAll('.wishlist-select-cb');
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
btn.textContent = allChecked ? 'Deselect All' : 'Select All';
}
}
/**
* Toggle all track checkboxes within an album when album header checkbox is clicked
*/
function toggleWishlistAlbumSelection(albumId, checked) {
const tracksContainer = document.getElementById(`tracks-${albumId}`);
if (tracksContainer) {
// Expand the album tracks if selecting
if (checked) {
tracksContainer.style.display = 'block';
const expandIcon = document.getElementById(`expand-icon-${albumId}`);
if (expandIcon) expandIcon.textContent = 'β²';
}
tracksContainer.querySelectorAll('.wishlist-select-cb').forEach(cb => {
cb.checked = checked;
});
}
updateWishlistBatchBar();
}
/**
* Batch remove selected tracks from wishlist
*/
async function batchRemoveFromWishlist() {
const checked = getCheckedWishlistTracks();
if (checked.length === 0) return;
const count = checked.length;
const confirmed = await showConfirmationModal(
'Remove Tracks',
`Remove ${count} track${count !== 1 ? 's' : ''} from your wishlist?`,
'ποΈ'
);
if (!confirmed) return;
const trackIds = checked.map(cb => cb.getAttribute('data-track-id'));
try {
const response = await fetch('/api/wishlist/remove-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spotify_track_ids: trackIds })
});
const data = await response.json();
if (data.success) {
showToast(`Removed ${data.removed} track(s) from wishlist`, 'success');
// Reload the current category to refresh the list
if (window.selectedWishlistCategory) {
await selectWishlistCategory(window.selectedWishlistCategory);
}
// Update wishlist count in sidebar
await updateWishlistCount();
} else {
showToast(`Failed to remove tracks: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error batch removing from wishlist:', error);
showToast('Failed to remove tracks from wishlist', 'error');
}
}
function showConfirmationModal(title, message, icon = 'β οΈ') {
return new Promise((resolve) => {
// Create modal if it doesn't exist
let modal = document.getElementById('confirmation-modal-overlay');
if (!modal) {
modal = document.createElement('div');
modal.id = 'confirmation-modal-overlay';
modal.className = 'confirmation-modal-overlay';
document.body.appendChild(modal);
}
// Set modal content
modal.innerHTML = `
${icon}
${title}
${message}
`;
// 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 = `
${generateDownloadModalHeroSection(heroContext)}
π Library Analysis
Ready to start
β¬ Downloads
Waiting for analysis
π Track Analysis & Download Status
#
Track
Artist
Library Match
Download Status
Actions
${tracks.map((track, index) => `
${index + 1}
${escapeHtml(track.name)}
${escapeHtml(formatArtists(track.artists))}
π Pending
-
-
`).join('')}
`;
applyProgressiveTrackRendering(playlistId, tracks.length);
modal.style.display = 'flex';
hideLoadingOverlay();
WishlistModalState.setVisible(); // Track that new wishlist modal is now visible
}
async function startWishlistMissingTracksProcess(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
console.log(`π Kicking off wishlist missing tracks process`);
try {
process.status = 'running';
// Note: Wishlist processes don't affect sync page refresh button state
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none';
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block';
// Check if force download toggle is enabled
const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`);
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
// Hide the force download toggle during processing
const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null;
if (forceToggleContainer) {
forceToggleContainer.style.display = 'none';
}
// Extract track IDs from what the user is currently seeing in the modal
// This prevents race conditions where wishlist changes between modal open and analysis start
const trackIds = process.tracks ? process.tracks.map(t => t.spotify_track_id || t.id).filter(id => id) : null;
console.log(`π― [Wishlist] Sending ${trackIds ? trackIds.length : 'all'} specific track IDs to prevent race condition`);
const response = await fetch('/api/wishlist/download_missing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
force_download_all: forceDownloadAll,
category: window.currentWishlistCategory, // Keep for backward compat
track_ids: trackIds // NEW: Send exact tracks to process
})
});
const data = await response.json();
if (!data.success) {
// Special handling for auto-processing conflict
if (response.status === 409) {
console.log('π€ [Wishlist] Auto-processing is running, redirecting to download manager');
showToast('Wishlist auto-processing is already running. Opening Download Manager...', 'info');
// Close wishlist modal and show download manager
const wishlistModal = document.getElementById('download-modal-wishlist');
if (wishlistModal) {
wishlistModal.remove();
}
delete activeDownloadProcesses[playlistId];
// Open download manager to show active batch
setTimeout(() => {
const downloadManager = document.getElementById('download-manager-modal');
if (downloadManager) {
downloadManager.style.display = 'flex';
} else {
openDownloadManagerModal();
}
}, 300);
return;
}
// Special handling for rate limit
if (response.status === 429) {
throw new Error(`${data.error} Try closing some other download processes first.`);
}
throw new Error(data.error);
}
process.batchId = data.batch_id;
console.log(`β Wishlist process started successfully. Batch ID: ${data.batch_id}`);
// Start polling for updates
startModalDownloadPolling(playlistId);
} catch (error) {
console.error('Error starting wishlist missing tracks process:', error);
showToast(`Error: ${error.message}`, 'error');
// Reset UI state on error
process.status = 'idle';
// Note: Wishlist processes don't affect sync page refresh button state
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'inline-block';
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none';
// Show the force download toggle again
const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container');
if (forceToggleContainer) {
forceToggleContainer.style.display = 'flex';
}
}
}
async function startMissingTracksProcess(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
console.log(`π Kicking off unified missing tracks process for playlist: ${playlistId}`);
try {
process.status = 'running';
updatePlaylistCardUI(playlistId);
updateRefreshButtonState();
// Set album to downloading status if this is an artist album
if (playlistId.startsWith('artist_album_')) {
// Format: artist_album_{artist.id}_{album.id}
const parts = playlistId.split('_');
if (parts.length >= 4) {
const albumId = parts.slice(3).join('_'); // In case album ID has underscores
const totalTracks = process.tracks ? process.tracks.length : 0;
setAlbumDownloadingStatus(albumId, 0, totalTracks);
console.log(`π Set album ${albumId} to downloading status (0/${totalTracks} tracks)`);
console.log(`π Virtual playlist ID: ${playlistId} β Album ID: ${albumId}`);
}
}
// Update YouTube playlist phase to 'downloading' if this is a YouTube playlist
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'downloading');
// Also update mirrored playlist card if applicable
if (urlHash.startsWith('mirrored_')) {
updateMirroredCardPhase(urlHash, 'downloading');
}
}
// Update Tidal playlist phase to 'downloading' if this is a Tidal playlist
if (playlistId.startsWith('tidal_')) {
const tidalPlaylistId = playlistId.replace('tidal_', '');
if (tidalPlaylistStates[tidalPlaylistId]) {
tidalPlaylistStates[tidalPlaylistId].phase = 'downloading';
updateTidalCardPhase(tidalPlaylistId, 'downloading');
console.log(`π Updated Tidal playlist ${tidalPlaylistId} to downloading phase`);
}
}
// Update Beatport chart phase to 'downloading' if this is a Beatport chart
if (playlistId.startsWith('beatport_')) {
const urlHash = playlistId.replace('beatport_', '');
const state = youtubePlaylistStates[urlHash];
if (state && state.is_beatport_playlist) {
const chartHash = state.beatport_chart_hash || urlHash;
// Update frontend states
state.phase = 'downloading';
if (beatportChartStates[chartHash]) {
beatportChartStates[chartHash].phase = 'downloading';
}
// Update card UI
updateBeatportCardPhase(chartHash, 'downloading');
// Update backend state
try {
fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'downloading' })
});
} catch (error) {
console.warn('β οΈ Error updating backend Beatport phase to downloading:', error);
}
console.log(`π Updated Beatport chart ${chartHash} to downloading phase`);
}
}
// Update Spotify Public playlist phase to 'downloading' if this is a Spotify Public playlist
if (playlistId.startsWith('spotify_public_')) {
const urlHash = playlistId.replace('spotify_public_', '');
if (spotifyPublicPlaylistStates[urlHash]) {
spotifyPublicPlaylistStates[urlHash].phase = 'downloading';
spotifyPublicPlaylistStates[urlHash].convertedSpotifyPlaylistId = playlistId;
updateSpotifyPublicCardPhase(urlHash, 'downloading');
try {
fetch(`/api/spotify-public/update_phase/${urlHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId })
});
} catch (error) {
console.warn('Error updating backend Spotify Public phase to downloading:', error);
}
console.log(`π Updated Spotify Public playlist ${urlHash} to downloading phase`);
}
}
// Update Deezer playlist phase to 'downloading' if this is a Deezer playlist
if (playlistId.startsWith('deezer_')) {
const deezerPlaylistId = playlistId.replace('deezer_', '');
if (deezerPlaylistStates[deezerPlaylistId]) {
deezerPlaylistStates[deezerPlaylistId].phase = 'downloading';
deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId = playlistId;
updateDeezerCardPhase(deezerPlaylistId, 'downloading');
try {
fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId })
});
} catch (error) {
console.warn('Error updating backend Deezer phase to downloading:', error);
}
console.log(`π Updated Deezer playlist ${deezerPlaylistId} to downloading phase`);
}
}
// Update ListenBrainz playlist phase to 'downloading' if this is a ListenBrainz playlist
if (playlistId.startsWith('listenbrainz_')) {
const playlistMbid = playlistId.replace('listenbrainz_', '');
const state = listenbrainzPlaylistStates[playlistMbid];
if (state) {
// Update frontend state
state.phase = 'downloading';
// Update backend state
try {
fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'downloading' })
});
} catch (error) {
console.warn('β οΈ Error updating backend ListenBrainz phase to downloading:', error);
}
console.log(`π Updated ListenBrainz playlist ${playlistMbid} to downloading phase`);
}
}
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none';
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block';
// Hide wishlist button if it exists (only for non-wishlist modals)
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`);
if (wishlistBtn) {
wishlistBtn.style.display = 'none';
}
// Add to discover download sidebar if this is a discover page download
if (process.discoverMetadata) {
const playlistName = process.playlist.name;
const imageUrl = process.discoverMetadata.imageUrl;
const type = process.discoverMetadata.type;
addDiscoverDownload(playlistId, playlistName, type, imageUrl);
console.log(`π₯ [BEGIN ANALYSIS] Added discover download: ${playlistName}`);
}
// Check if force download toggle is enabled
const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`);
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
// Check if playlist folder mode toggle is enabled (only for sync page playlists)
const playlistFolderModeCheckbox = document.getElementById(`playlist-folder-mode-${playlistId}`);
const playlistFolderMode = playlistFolderModeCheckbox ? playlistFolderModeCheckbox.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,
wing_it: isWingIt,
};
// 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}`);
}
}
const response = await fetch(`/api/playlists/${playlistId}/start-missing-process`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
const 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'}`;
}
}
}
// ============================================================================
// 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) showCandidatesModal(taskId);
});
}
async function showCandidatesModal(taskId) {
try {
const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/candidates`);
if (!resp.ok) { console.error('Failed to fetch candidates:', resp.status); return; }
const data = await resp.json();
_renderCandidatesModal(data);
} catch (err) {
console.error('Error fetching candidates:', err);
}
}
// Format helpers used by both auto-candidates and manual-search rendering.
function _candidatesFmtSize(bytes) {
if (!bytes) return '-';
const units = ['B', 'KB', 'MB', 'GB'];
let s = bytes, u = 0;
while (s >= 1024 && u < units.length - 1) { s /= 1024; u++; }
return `${s.toFixed(1)} ${units[u]}`;
}
function _candidatesFmtDur(ms) {
if (!ms) return '-';
const sec = Math.floor(ms / 1000);
return `${Math.floor(sec / 60)}:${(sec % 60).toString().padStart(2, '0')}`;
}
// Build a single
for the candidates table. ``rowClass`` lets the
// manual-search renderer distinguish its rows from the auto-candidates
// rows (different click binding scope). ``showSourceBadge`` adds a small
// per-row source pill β used in hybrid "All sources" mode where the user
// otherwise can't tell which source a row came from.
function _renderCandidateRow(c, index, rowClass, showSourceBadge) {
const shortFile = c.filename ? c.filename.split(/[/\\]/).pop() : '-';
const qBadge = c.quality
? `${c.quality.toUpperCase()}`
: '';
const sourceBadge = (showSourceBadge && c.source)
? `${escapeHtml(c.source)} `
: '';
return `
`;
results.classList.add('visible');
return;
}
// No cache, not loading β source switch before fetch fired (e.g. empty query).
if (!cached) {
body.innerHTML = '
Click the source above to search.
';
results.classList.add('visible');
return;
}
// Music Videos β video grid instead of regular sections.
if (activeSrc === 'youtube_videos') {
const videos = cached.videos || [];
let h = `
';
body.innerHTML = h;
results.classList.add('visible');
// Lazy load artist images for sources that don't provide them (iTunes/Deezer).
_gsLazyLoadArtistImages();
// Library ownership check β adds "In Library" badges + swaps play buttons.
// Idempotent enough to run on every render with a cache hit; the old flow
// also fired it on both cache-hit and fetch-settle.
setTimeout(() => _gsLibraryCheck(), 200);
}
async function _gsLazyLoadArtistImages() {
const grid = document.getElementById('gsearch-artists-grid');
if (!grid) return;
const cards = grid.querySelectorAll('[data-needs-image="true"]');
if (cards.length === 0) return;
const activeSrc = (_gsController && _gsController.state.activeSource) || 'spotify';
for (const card of cards) {
const artistId = card.dataset.artistId;
if (!artistId) continue;
try {
// Pass the artist name so MusicBrainz lookups (which have no
// artist art) can resolve the image by name on a fallback source.
const params = new URLSearchParams({ source: activeSrc });
if (card.dataset.artistName) params.set('name', card.dataset.artistName);
const res = await fetch(`/api/artist/${artistId}/image?${params}`);
const data = await res.json();
if (data.success && data.image_url) {
const artDiv = card.querySelector('.gsearch-item-art');
if (artDiv) artDiv.innerHTML = ``;
card.removeAttribute('data-needs-image');
}
} catch (e) { /* ignore */ }
}
}
function _gsClickArtist(id, name, isLibrary) {
_gsDeactivate();
const activeSource = _gsController && _gsController.state.activeSource;
const source = isLibrary ? null : (activeSource || null);
navigateToArtistDetail(id, name, source);
}
async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) {
_gsDeactivate();
// Same flow as handleEnhancedSearchAlbumClick β fetch album, open download modal
showLoadingOverlay('Loading album...');
try {
const params = new URLSearchParams({ name: albumName, artist: artistName });
if (source && source !== 'spotify') params.set('source', source);
const response = await fetch(`/api/spotify/album/${albumId}?${params}`);
if (!response.ok) throw new Error(`Failed to load album: ${response.status}`);
const albumData = await response.json();
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
hideLoadingOverlay();
showToast(`No tracks available for "${albumName}"`, 'warning');
return;
}
const enrichedTracks = albumData.tracks.map(t => ({
...t,
album: { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks }
}));
const virtualPlaylistId = `enhanced_search_album_${albumId}`;
const firstArtist = (albumData.artists || [])[0] || {};
const artistObj = { id: firstArtist.id || '', name: firstArtist.name || artistName, source: source || '' };
const albumObj = { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks, artists: albumData.artists || [{ name: artistName }] };
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, `[${artistName}] ${albumData.name}`, enrichedTracks, albumObj, artistObj, false);
// Register download bubble (same pattern as enhanced search)
registerSearchDownload(
{
id: albumData.id,
name: albumData.name,
artist: artistName,
image_url: albumData.images?.[0]?.url || imageUrl || null,
images: albumData.images || []
},
'album',
virtualPlaylistId,
artistName
);
} catch (e) {
hideLoadingOverlay();
showToast('Failed to load album: ' + e.message, 'error');
}
}
async function _gsClickTrack(artistName, trackName, albumName, trackId, imageUrl, durationMs) {
_gsDeactivate();
// Build enriched track + open download modal directly (same as enhanced search)
const virtualPlaylistId = `gsearch_track_${trackId || (artistName + '_' + trackName).replace(/\s/g, '_')}`;
const enrichedTrack = {
id: trackId || '',
name: trackName,
artists: [artistName],
album: { name: albumName || '', id: null, album_type: 'single', images: imageUrl ? [{ url: imageUrl }] : [], total_tracks: 1 },
duration_ms: durationMs || 0,
image_url: imageUrl || '',
};
const albumObject = {
name: albumName || '', id: null, album_type: 'single',
images: imageUrl ? [{ url: imageUrl }] : [],
artists: [{ name: artistName }], total_tracks: 1,
};
const artistObject = { id: null, name: artistName };
const playlistName = `${artistName} - ${trackName}`;
try {
showLoadingOverlay('Loading track...');
await openDownloadMissingModalForArtistAlbum(
virtualPlaylistId, playlistName, [enrichedTrack], albumObject, artistObject, false
);
// Register download bubble (same pattern as enhanced search)
registerSearchDownload(
{
id: trackId || '',
name: trackName,
artist: artistName,
image_url: imageUrl || null,
images: imageUrl ? [{ url: imageUrl }] : []
},
'track',
virtualPlaylistId,
artistName
);
} catch (e) {
console.error('Error opening track download:', e);
// Fallback: navigate to the unified Search page
navigateToPage('search');
setTimeout(() => {
const input = document.getElementById('enhanced-search-input');
if (input) { input.value = `${artistName} ${trackName}`.trim(); input.dispatchEvent(new Event('input')); }
}, 300);
} finally {
hideLoadingOverlay();
}
}
async function _gsPlayTrack(trackName, artistName, albumName) {
try {
showToast('Searching for stream...', 'info');
const res = await fetch('/api/enhanced-search/stream-track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_name: trackName, artist_name: artistName, album_name: albumName })
});
const data = await res.json();
if (data.success && data.result) {
if (typeof startStream === 'function') {
startStream(data.result);
} else {
showToast('Streaming not available', 'error');
}
} else {
showToast(data.error || 'No stream found', 'error');
}
} catch (e) {
showToast('Stream failed: ' + e.message, 'error');
}
}
// Async library check for global search results β adds badges + swaps play buttons
async function _gsLibraryCheck() {
try {
if (!_gsController) return;
const src = _gsController.state.sources[_gsController.state.activeSource] || {};
const allAlbums = src.albums || [];
const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation');
const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep');
const tracks = src.tracks || [];
if (!allAlbums.length && !tracks.length) return;
const res = await fetch('/api/enhanced-search/library-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
albums: allAlbums.map(a => ({ name: a.name, artist: a.artist || (a.artists ? a.artists.join(', ') : '') })),
tracks: tracks.map(t => ({ name: t.name, artist: t.artist || (t.artists ? t.artists.join(', ') : '') })),
})
});
const checkData = await res.json();
// Add "In Library" badges to albums β match by index against allAlbums order
const albumResults = checkData.albums || [];
let albumIdx = 0;
// Albums section
document.querySelectorAll('#gsearch-results .gsearch-results-body').forEach(body => {
// Find all gsearch-item elements and tag ones that are albums
const sections = body.querySelectorAll('.gsearch-section-header');
sections.forEach(header => {
const text = header.textContent;
const isAlbumSection = text.includes('Albums') || text.includes('Singles');
if (!isAlbumSection) return;
const grid = header.nextElementSibling;
if (!grid) return;
const items = grid.querySelectorAll('.gsearch-item');
items.forEach(item => {
if (albumIdx < albumResults.length && albumResults[albumIdx]) {
if (!item.querySelector('.gsearch-item-badge')) {
const badge = document.createElement('span');
badge.className = 'gsearch-item-badge';
badge.textContent = 'In Library';
item.appendChild(badge);
}
}
albumIdx++;
});
});
});
// Tag tracks + swap play buttons for library playback
const trackResults = checkData.tracks || [];
const trackEls = document.querySelectorAll('#gsearch-results .gsearch-track');
trackEls.forEach((el, i) => {
const tr = trackResults[i];
if (tr && tr.in_library) {
// Add badge
if (!el.querySelector('.gsearch-item-badge')) {
const badge = document.createElement('span');
badge.className = 'gsearch-item-badge';
badge.textContent = 'In Library';
badge.style.marginRight = '4px';
el.querySelector('.gsearch-track-dur')?.before(badge);
}
// Swap play button to library playback
if (tr.file_path) {
const playBtn = el.querySelector('.gsearch-play-btn');
if (playBtn) {
const newBtn = playBtn.cloneNode(true);
newBtn.removeAttribute('onclick');
newBtn.title = 'Play from library';
newBtn.style.background = 'rgba(76,175,80,0.15)';
newBtn.style.color = '#4caf50';
newBtn.addEventListener('click', e => {
e.stopPropagation();
playLibraryTrack(
{ id: tr.track_id, title: tr.title, file_path: tr.file_path, _stats_image: tr.album_thumb_url || null },
tr.album_title || '',
tr.artist_name || ''
);
});
playBtn.replaceWith(newBtn);
}
}
} else if (tr && tr.in_wishlist) {
if (!el.querySelector('.gsearch-item-badge')) {
const badge = document.createElement('span');
badge.className = 'gsearch-item-badge gsearch-wishlist-badge';
badge.textContent = 'In Wishlist';
badge.style.marginRight = '4px';
el.querySelector('.gsearch-track-dur')?.before(badge);
}
}
});
} catch (e) {
// Non-critical
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Escape a value for safe use inside a single-quoted JS string literal
* within a double-quoted HTML attribute (e.g. onclick="fn('${val}')").
*
* Layer 1 (JS): escape \ and ' so the JS string parses correctly.
* Layer 2 (HTML): escape &, ", <, > so the HTML attribute parses correctly.
* The browser applies these in reverse: HTML-decode first, then JS-execute.
*/
function escapeForInlineJs(str) {
if (str == null) return '';
return String(str)
.replace(/\\/g, '\\\\') // JS: literal backslash
.replace(/'/g, "\\'") // JS: single quote
.replace(/&/g, '&') // HTML: ampersand
.replace(/"/g, '"') // HTML: double quote
.replace(//g, '>'); // HTML: greater-than
}
function formatArtists(artists) {
if (!artists || !Array.isArray(artists)) {
return 'Unknown Artist';
}
// Handle both string arrays and object arrays with 'name' property
const artistNames = artists.map(artist => {
let artistName;
if (typeof artist === 'string') {
artistName = artist;
} else if (artist && typeof artist === 'object' && artist.name) {
artistName = artist.name;
} else {
artistName = 'Unknown Artist';
}
// Clean featured artists from the name
return cleanArtistName(artistName);
});
return artistNames.join(', ') || 'Unknown Artist';
}
async function checkForUpdates() {
try {
const res = await fetch('/api/update-check');
if (!res.ok) return;
const data = await res.json();
const btn = document.querySelector('.version-button');
if (!btn) return;
if (data.update_available) {
const dismissed = localStorage.getItem('soulsync-update-dismissed');
if (dismissed !== data.latest_sha) {
// Add glow class
btn.classList.add('update-available');
// Add UPDATE badge if not already present
if (!btn.querySelector('.update-badge')) {
const badge = document.createElement('span');
badge.className = 'update-badge';
badge.textContent = 'UPDATE';
btn.appendChild(badge);
}
// Show toast on first detection (not if already notified this session)
const notified = sessionStorage.getItem('soulsync-update-notified');
if (notified !== data.latest_sha) {
sessionStorage.setItem('soulsync-update-notified', data.latest_sha);
showToast(data.is_docker
? 'A new SoulSync update has been pushed to the repo β Docker image will be updated soon!'
: 'A new SoulSync update is available!', 'info');
}
}
} else {
btn.classList.remove('update-available');
const badge = btn.querySelector('.update-badge');
if (badge) badge.remove();
}
} catch (e) {
console.debug('Update check failed:', e);
}
}
async function showVersionInfo() {
// Check update status before dismissing so we can pass it to the modal
let updateInfo = null;
const btn = document.querySelector('.version-button');
const hadUpdate = btn && btn.classList.contains('update-available');
// Dismiss update glow when user opens the modal
if (hadUpdate) {
btn.classList.remove('update-available');
const badge = btn.querySelector('.update-badge');
if (badge) badge.remove();
try {
const updateRes = await fetch('/api/update-check');
if (updateRes.ok) {
updateInfo = await updateRes.json();
if (updateInfo.latest_sha) {
localStorage.setItem('soulsync-update-dismissed', updateInfo.latest_sha);
}
}
} catch (e) { /* ignore */ }
}
// Build version data straight from helper.js β single source of truth.
// No backend round-trip; the changelog content is shipped in the same
// bundle the browser already loaded.
const version = (typeof _getCurrentVersion === 'function')
? _getCurrentVersion()
: (btn ? btn.textContent.trim().replace('v', '') : '');
const sections = (typeof VERSION_MODAL_SECTIONS !== 'undefined')
? VERSION_MODAL_SECTIONS
: [];
const versionData = {
version,
title: "What's New in SoulSync",
subtitle: version ? `Version ${version} β Latest Changes` : 'Latest Changes',
sections,
};
try {
populateVersionModal(versionData, hadUpdate ? updateInfo : null);
const modalOverlay = document.getElementById('version-modal-overlay');
if (modalOverlay) modalOverlay.classList.remove('hidden');
} catch (error) {
console.error('Error showing version info:', error);
showToast('Failed to load version information', 'error');
}
}
function closeVersionModal() {
const modalOverlay = document.getElementById('version-modal-overlay');
modalOverlay.classList.add('hidden');
console.log('Version modal closed');
}
function populateVersionModal(versionData, updateInfo) {
const container = document.getElementById('version-content-container');
if (!container) {
console.error('Version content container not found');
return;
}
// Update header with dynamic data
const titleElement = document.querySelector('.version-modal-title');
const subtitleElement = document.querySelector('.version-modal-subtitle');
if (titleElement) titleElement.textContent = versionData.title;
if (subtitleElement) subtitleElement.textContent = versionData.subtitle;
// Clear existing content
container.innerHTML = '';
// Show update banner if an update was available when modal was opened
if (updateInfo && updateInfo.update_available) {
const banner = document.createElement('div');
banner.className = 'version-update-banner';
const isDocker = updateInfo.is_docker;
banner.innerHTML = `
⬆
${isDocker ? 'Repo update detected' : 'New update available'}${isDocker
? 'A new update has been pushed to the repo. The Docker image will be updated soon β no action needed yet.'
: `Your version: ${updateInfo.current_sha || 'unknown'} → Latest: ${updateInfo.latest_sha || 'unknown'}`}