No artists found for "' + query.replace(/';
resultsContainer.style.display = 'block';
return;
}
// Filter out already-selected artists
const selectedIds = new Set(buildPlaylistSelectedArtists.map(a => a.id));
const filtered = data.artists.filter(a => !selectedIds.has(a.id));
if (filtered.length === 0) {
resultsContainer.innerHTML = '
All results already selected
';
resultsContainer.style.display = 'block';
return;
}
// Render search results
let html = '';
filtered.forEach(artist => {
const imageUrl = artist.image_url || '/static/placeholder-album.png';
const escapedName = artist.name.replace(/'/g, "\\'").replace(/"/g, '"');
html += `
${artist.name}
+ Add
`;
});
resultsContainer.innerHTML = html;
resultsContainer.style.display = 'block';
} catch (error) {
console.error('Error searching artists:', error);
} finally {
if (spinner) spinner.style.display = 'none';
}
}, 400);
}
function addBuildPlaylistArtist(artistId, artistName, imageUrl) {
if (buildPlaylistSelectedArtists.some(a => a.id === artistId)) {
showToast('Artist already selected', 'warning');
return;
}
if (buildPlaylistSelectedArtists.length >= 5) {
showToast('Maximum 5 seed artists', 'warning');
return;
}
buildPlaylistSelectedArtists.push({
id: artistId,
name: artistName,
image_url: imageUrl
});
renderBuildPlaylistSelectedArtists();
// Clear search
document.getElementById('build-playlist-search').value = '';
document.getElementById('build-playlist-search-results').innerHTML = '';
document.getElementById('build-playlist-search-results').style.display = 'none';
}
function removeBuildPlaylistArtist(artistId) {
buildPlaylistSelectedArtists = buildPlaylistSelectedArtists.filter(a => a.id !== artistId);
renderBuildPlaylistSelectedArtists();
}
function renderBuildPlaylistSelectedArtists() {
const container = document.getElementById('build-playlist-selected-artists');
const generateBtn = document.getElementById('build-playlist-generate-btn');
const counter = document.getElementById('bp-selected-counter');
const count = buildPlaylistSelectedArtists.length;
if (counter) counter.textContent = `${count} / 5`;
if (count === 0) {
container.innerHTML = `
Search above to add seed artists
`;
generateBtn.disabled = true;
return;
}
let html = '';
buildPlaylistSelectedArtists.forEach(artist => {
const escapedId = artist.id.replace(/'/g, "\\'");
html += `
${artist.name}
Ã
`;
});
container.innerHTML = html;
generateBtn.disabled = false;
}
let buildPlaylistTracks = [];
async function generateBuildPlaylist() {
if (buildPlaylistSelectedArtists.length === 0) {
showToast('Please select at least 1 artist', 'warning');
return;
}
const generateBtn = document.getElementById('build-playlist-generate-btn');
const resultsContainer = document.getElementById('build-playlist-results');
const resultsWrapper = document.getElementById('build-playlist-results-wrapper');
const loadingIndicator = document.getElementById('build-playlist-loading');
const metadataDisplay = document.getElementById('build-playlist-metadata-display');
const titleEl = document.getElementById('build-playlist-results-title');
const subtitleEl = document.getElementById('build-playlist-results-subtitle');
// Show loading, hide search area
generateBtn.disabled = true;
loadingIndicator.style.display = 'flex';
resultsWrapper.style.display = 'none';
resultsContainer.innerHTML = '';
try {
const seedIds = buildPlaylistSelectedArtists.map(a => a.id);
const response = await fetch('/api/discover/build-playlist/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
seed_artist_ids: seedIds,
playlist_size: 50
})
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to generate playlist');
}
if (!data.playlist || !data.playlist.tracks || data.playlist.tracks.length === 0) {
throw new Error(data.playlist?.error || 'No tracks found. Try different seed artists.');
}
// Store tracks globally
buildPlaylistTracks = data.playlist.tracks;
// Update title and subtitle
const artistNames = buildPlaylistSelectedArtists.map(a => a.name).join(', ');
titleEl.textContent = 'Custom Playlist';
subtitleEl.textContent = `Based on: ${artistNames}`;
// Render metadata
const metadata = data.playlist.metadata;
metadataDisplay.innerHTML = `
`;
// Render playlist
renderCompactPlaylist(resultsContainer, data.playlist.tracks);
// Show results wrapper
resultsWrapper.style.display = 'block';
} catch (error) {
console.error('Error generating playlist:', error);
resultsWrapper.style.display = 'none';
showToast(error.message || 'Failed to generate playlist', 'error');
} finally {
loadingIndicator.style.display = 'none';
generateBtn.disabled = false;
}
}
async function openDownloadModalForBuildPlaylist() {
if (!buildPlaylistTracks || buildPlaylistTracks.length === 0) {
showToast('No playlist tracks available', 'warning');
return;
}
const artistNames = buildPlaylistSelectedArtists.map(a => a.name).join(', ');
const playlistName = `Custom Playlist - ${artistNames}`;
const virtualPlaylistId = 'build_playlist_custom';
// Open download modal
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, buildPlaylistTracks);
}
function openDailyMix(mixIndex) {
const mix = personalizedDailyMixes[mixIndex];
if (!mix || !mix.tracks) return;
// TODO: Open modal or dedicated view for Daily Mix
console.log('Opening Daily Mix:', mix.name);
}
// ===============================
// DISCOVER PLAYLIST ACTIONS
// ===============================
async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName) {
console.log(`đĨ Opening Download Missing Tracks modal for ${playlistName}`);
try {
// Get tracks based on playlist type
let tracks = [];
if (playlistType === 'release_radar') {
tracks = discoverReleaseRadarTracks;
} else if (playlistType === 'discovery_weekly') {
tracks = discoverWeeklyTracks;
} else if (playlistType === 'seasonal_playlist') {
tracks = discoverSeasonalTracks;
} else if (playlistType === 'popular_picks') {
tracks = personalizedPopularPicks;
} else if (playlistType === 'hidden_gems') {
tracks = personalizedHiddenGems;
} else if (playlistType === 'discovery_shuffle') {
tracks = personalizedDiscoveryShuffle;
} else if (playlistType === 'familiar_favorites') {
tracks = personalizedFamiliarFavorites;
} else if (playlistType === 'recently_added') {
tracks = personalizedRecentlyAdded;
} else if (playlistType === 'top_tracks') {
tracks = personalizedTopTracks;
} else if (playlistType === 'forgotten_favorites') {
tracks = personalizedForgottenFavorites;
} else if (playlistType === 'build_playlist') {
tracks = buildPlaylistTracks;
}
if (!tracks || tracks.length === 0) {
showToast(`No tracks available for ${playlistName}`, 'warning');
return;
}
// Convert discover tracks to format expected by download modal
const spotifyTracks = tracks.map(track => {
let spotifyTrack;
// Use track_data_json if available, otherwise construct from track data
if (track.track_data_json) {
spotifyTrack = track.track_data_json;
} else {
// Fallback: construct track object from available data
spotifyTrack = {
id: track.spotify_track_id,
name: track.track_name,
artists: [{ name: track.artist_name }],
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0
};
}
// Normalize artists to array of strings for modal compatibility
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
}
return spotifyTrack;
});
// Create virtual playlist ID
const virtualPlaylistId = `discover_${playlistType}`;
// Use existing modal system (same as YouTube/Tidal playlists)
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
} catch (error) {
console.error('Error opening download modal for discover playlist:', error);
showToast(`Failed to open download modal: ${error.message}`, 'error');
hideLoadingOverlay(); // Ensure overlay is hidden on error
}
}
function updateDiscoverDownloadButton(playlistType, state) {
/**
* Update the download button appearance based on download state
* @param {string} playlistType - 'release_radar' or 'discovery_weekly'
* @param {string} state - 'idle', 'downloading', or 'complete'
*/
const buttonId = `${playlistType}-download-btn`;
const button = document.getElementById(buttonId);
if (!button) return;
const icon = button.querySelector('.button-icon');
const text = button.querySelector('.button-text');
if (state === 'downloading') {
if (icon) icon.textContent = 'âŗ';
if (text) text.textContent = 'View Progress';
button.title = 'View download progress';
} else {
if (icon) icon.textContent = 'â';
if (text) text.textContent = 'Download';
button.title = 'Download missing tracks';
}
}
function checkForActiveDiscoverDownloads() {
/**
* Check for active download processes and update button states
* Only runs if discover page is actually loaded in the DOM
*/
// Check if discover page is loaded by looking for a discover-specific element
const discoverPage = document.getElementById('release-radar-download-btn') ||
document.getElementById('discovery-weekly-download-btn');
if (!discoverPage) return;
const discoverPlaylists = [
{ id: 'discover_release_radar', type: 'release_radar' },
{ id: 'discover_discovery_weekly', type: 'discovery_weekly' }
];
discoverPlaylists.forEach(({ id, type }) => {
if (activeDownloadProcesses[id]) {
const process = activeDownloadProcesses[id];
if (process.status === 'running' || process.status === 'idle') {
updateDiscoverDownloadButton(type, 'downloading');
}
}
});
}
async function startDiscoverPlaylistSync(playlistType, playlistName) {
console.log(`đ Navigating to Sync â Discover tab for ${playlistName}`);
// Navigate to the Sync page â Discover tab, highlight the card, and auto-sync
navigateToSyncTab('discover', {
highlight: `discover-sync-card-${playlistType}`,
autoSync: playlistType,
autoSyncName: playlistName,
});
}
// Track active discover sync pollers
const discoverSyncPollers = {};
function startDiscoverSyncPolling(playlistType, virtualPlaylistId) {
// Stop any existing poller for this playlist type
if (discoverSyncPollers[playlistType]) {
clearInterval(discoverSyncPollers[playlistType]);
}
console.log(`đ Starting sync polling for ${playlistType} (${virtualPlaylistId})`);
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] });
_syncProgressCallbacks[virtualPlaylistId] = (data) => {
const prefix = playlistType.replace(/_/g, '-');
const progress = data.progress || {};
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const processed = matched + failed;
const pending = total - processed;
const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
const el = (id) => document.getElementById(id);
if (el(`${prefix}-sync-completed`)) el(`${prefix}-sync-completed`).textContent = matched;
if (el(`${prefix}-sync-pending`)) el(`${prefix}-sync-pending`).textContent = pending;
if (el(`${prefix}-sync-failed`)) el(`${prefix}-sync-failed`).textContent = failed;
if (el(`${prefix}-sync-percentage`)) el(`${prefix}-sync-percentage`).textContent = pct;
if (data.status === 'finished') {
if (discoverSyncPollers[playlistType]) { clearInterval(discoverSyncPollers[playlistType]); delete discoverSyncPollers[playlistType]; }
socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] });
delete _syncProgressCallbacks[virtualPlaylistId];
const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn';
const syncButton = el(buttonId);
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
const playlistNames = {
'release_radar': 'Fresh Tape', 'discovery_weekly': 'The Archives',
'seasonal_playlist': 'Seasonal Mix', 'popular_picks': 'Popular Picks',
'hidden_gems': 'Hidden Gems', 'discovery_shuffle': 'Discovery Shuffle',
'familiar_favorites': 'Familiar Favorites', 'build_playlist': 'Custom Playlist'
};
const dn = playlistNames[playlistType] || playlistType;
const _m = progress.matched_tracks || matched || 0;
const _t = progress.total_tracks || total || 0;
const _miss = _t - _m;
if (_miss > 0) {
showToast(`${dn}: ${_m}/${_t} matched, ${_miss} missing`, 'warning');
} else {
showToast(`${dn}: all ${_t} tracks matched!`, 'success');
}
if (el(`${prefix}-sync-percentage`)) el(`${prefix}-sync-percentage`).textContent = '100';
if (el(`${prefix}-sync-pending`)) el(`${prefix}-sync-pending`).textContent = '0';
setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 5000);
}
};
}
// Poll every 500ms for progress updates
discoverSyncPollers[playlistType] = setInterval(async () => {
// Always poll â no dedicated WebSocket events for discovery progress
try {
const response = await fetch(`/api/sync/status/${virtualPlaylistId}`);
if (!response.ok) {
console.log(`â ī¸ Sync status response not OK: ${response.status}`);
return;
}
const data = await response.json();
console.log(`đ Sync status for ${playlistType}:`, data);
// Update UI with progress (data structure: {status: ..., progress: {...}})
// Convert underscores to hyphens for HTML IDs
const prefix = playlistType.replace(/_/g, '-');
const progress = data.progress || {};
const completedEl = document.getElementById(`${prefix}-sync-completed`);
const pendingEl = document.getElementById(`${prefix}-sync-pending`);
const failedEl = document.getElementById(`${prefix}-sync-failed`);
const percentageEl = document.getElementById(`${prefix}-sync-percentage`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const processed = matched + failed;
const pending = total - processed;
const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0;
if (completedEl) completedEl.textContent = matched;
if (pendingEl) pendingEl.textContent = pending;
if (failedEl) failedEl.textContent = failed;
if (percentageEl) percentageEl.textContent = completionPercentage;
// If complete, stop polling and hide status after delay
if (data.status === 'finished') {
console.log(`â
Sync complete for ${playlistType}`);
clearInterval(discoverSyncPollers[playlistType]);
delete discoverSyncPollers[playlistType];
// Re-enable sync button
const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn';
const syncButton = document.getElementById(buttonId);
if (syncButton) {
syncButton.disabled = false;
syncButton.style.opacity = '1';
syncButton.style.cursor = 'pointer';
}
// Show completion toast with playlist name
const playlistNames = {
'release_radar': 'Fresh Tape',
'discovery_weekly': 'The Archives',
'seasonal_playlist': 'Seasonal Mix',
'popular_picks': 'Popular Picks',
'hidden_gems': 'Hidden Gems',
'discovery_shuffle': 'Discovery Shuffle',
'familiar_favorites': 'Familiar Favorites',
'build_playlist': 'Custom Playlist'
};
const displayName = playlistNames[playlistType] || playlistType;
const missing = total - matched;
if (missing > 0) {
showToast(`${displayName}: ${matched}/${total} matched, ${missing} missing`, 'warning');
} else {
showToast(`${displayName}: all ${total} tracks matched!`, 'success');
}
// Update status display to show final result, then hide after 5s
if (percentageEl) percentageEl.textContent = '100';
if (pendingEl) pendingEl.textContent = '0';
setTimeout(() => {
const statusDisplay = document.getElementById(`${prefix}-sync-status`);
if (statusDisplay) {
statusDisplay.style.display = 'none';
}
}, 5000);
}
} catch (error) {
console.error(`â Error polling sync status for ${playlistType}:`, error);
}
}, 500);
}
async function openDownloadModalForRecentAlbum(albumIndex) {
const album = discoverRecentAlbums[albumIndex];
if (!album) {
showToast('Album data not found', 'error');
return;
}
console.log(`đĨ Opening Download Missing Tracks modal for album: ${album.album_name}`);
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
try {
// Determine source and album ID - use source-agnostic endpoint
const source = album.source || (album.album_spotify_id ? 'spotify' : album.album_deezer_id ? 'deezer' : 'itunes');
const albumId = source === 'spotify' ? album.album_spotify_id : source === 'deezer' ? album.album_deezer_id : album.album_itunes_id;
if (!albumId) {
throw new Error(`No ${source} album ID available`);
}
// Fetch album tracks from appropriate source (pass name/artist for Hydrabase support)
const _dap2 = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
const response = await fetch(`/api/discover/album/${source}/${albumId}?${_dap2}`);
if (!response.ok) {
throw new Error('Failed to fetch album tracks');
}
const albumData = await response.json();
if (!albumData.tracks || albumData.tracks.length === 0) {
throw new Error('No tracks found in album');
}
// Convert to expected format - CRITICAL FIX: Use fresh albumData from Spotify, not cached album
const spotifyTracks = albumData.tracks.map(track => {
// Normalize artists to array of strings
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
if (Array.isArray(artists)) {
artists = artists.map(a => a.name || a);
}
return {
id: track.id,
name: track.name,
artists: artists,
album: {
id: albumData.id, // â
Album ID for proper tracking
name: albumData.name, // â
Use fresh data, not cached
album_type: albumData.album_type || 'album', // â
Critical: Album type for classification
total_tracks: albumData.total_tracks || 0, // â
Total tracks for context
release_date: albumData.release_date || '', // â
Release date
images: albumData.images || [] // â
Use Spotify images
},
duration_ms: track.duration_ms || 0,
track_number: track.track_number || 0
};
});
// Create virtual playlist ID using the appropriate album ID
const virtualPlaylistId = `discover_album_${albumId}`;
// CRITICAL FIX: Pass proper artist/album context for modal display
const artistContext = {
id: source === 'spotify' ? album.artist_spotify_id : source === 'deezer' ? album.artist_deezer_id : album.artist_itunes_id,
name: album.artist_name,
source: source
};
const albumContext = {
id: albumData.id,
name: albumData.name,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
images: albumData.images || []
};
// Open download modal with artist/album context
await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext);
hideLoadingOverlay();
} catch (error) {
console.error('Error opening album download modal:', error);
showToast(`Failed to load album: ${error.message}`, 'error');
hideLoadingOverlay();
}
}
// ===============================
// DISCOVER DOWNLOAD BAR
// ===============================
// Track discover page downloads
let discoverDownloads = {}; // playlistId -> { name, type, status, virtualPlaylistId, startTime }
/**
* Add a download to the discover download bar
*/
function addDiscoverDownload(playlistId, playlistName, playlistType, imageUrl = null) {
console.log(`đĨ [DOWNLOAD SIDEBAR] Adding discover download: ${playlistName} (${playlistId}) type: ${playlistType}, image: ${imageUrl}`);
// Always register the download in state (needed for dashboard even when not on discover page)
discoverDownloads[playlistId] = {
name: playlistName,
type: playlistType,
status: 'in_progress',
virtualPlaylistId: playlistId,
imageUrl: imageUrl,
startTime: new Date()
};
console.log(`đ [DOWNLOAD SIDEBAR] Active downloads:`, Object.keys(discoverDownloads));
// Update discover page sidebar if it exists (user is on discover page)
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
updateDiscoverDownloadBar(); // Also saves snapshot internally
} else {
console.log('âšī¸ [DOWNLOAD SIDEBAR] Sidebar not present - skipping sidebar UI update');
saveDiscoverDownloadSnapshot(); // Persist state even when sidebar is absent
}
updateDashboardDownloads();
monitorDiscoverDownload(playlistId);
}
/**
* Monitor a discover download for completion
*/
function monitorDiscoverDownload(playlistId) {
let notFoundCount = 0;
const maxNotFoundAttempts = 5; // Give sync 10 seconds to start (5 checks * 2 seconds)
// Phase 5: Subscribe via WebSocket for sync status updates
if (socketConnected) {
socket.emit('sync:subscribe', { playlist_ids: [playlistId] });
_syncProgressCallbacks[playlistId] = (data) => {
if (!discoverDownloads[playlistId]) return;
if (data.status === 'complete' || data.status === 'finished') {
discoverDownloads[playlistId].status = 'completed';
updateDiscoverDownloadBar();
updateDashboardDownloads();
socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] });
delete _syncProgressCallbacks[playlistId];
setTimeout(() => {
if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') {
removeDiscoverDownload(playlistId);
}
}, 30000);
}
};
}
const checkInterval = setInterval(async () => {
try {
// Check if download still exists
if (!discoverDownloads[playlistId]) {
clearInterval(checkInterval);
if (_syncProgressCallbacks[playlistId]) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] });
delete _syncProgressCallbacks[playlistId];
}
return;
}
// First check if there's an active download process (modal-based downloads)
const activeProcess = activeDownloadProcesses[playlistId];
if (activeProcess) {
console.log(`đ [DOWNLOAD BAR] Found active process for ${playlistId}, status: ${activeProcess.status}`);
if (activeProcess.status === 'complete') {
console.log(`â
[DOWNLOAD BAR] Process completed: ${discoverDownloads[playlistId].name}`);
discoverDownloads[playlistId].status = 'completed';
updateDiscoverDownloadBar();
updateDashboardDownloads();
clearInterval(checkInterval);
// Auto-remove completed downloads after 30 seconds
setTimeout(() => {
if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') {
removeDiscoverDownload(playlistId);
}
}, 30000);
}
return; // Continue monitoring
}
// Check sync status API (for sync-based downloads)
if (socketConnected) return; // Phase 5: WS handles sync status
const response = await fetch(`/api/sync/status/${playlistId}`);
if (response.ok) {
const data = await response.json();
notFoundCount = 0; // Reset counter if found
console.log(`đ [DOWNLOAD BAR] Sync status for ${playlistId}: ${data.status}`);
if (data.status === 'complete') {
console.log(`â
[DOWNLOAD BAR] Sync completed: ${discoverDownloads[playlistId].name}`);
discoverDownloads[playlistId].status = 'completed';
updateDiscoverDownloadBar();
updateDashboardDownloads();
clearInterval(checkInterval);
// Auto-remove completed downloads after 30 seconds
setTimeout(() => {
if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') {
removeDiscoverDownload(playlistId);
}
}, 30000);
}
} else if (response.status === 404) {
notFoundCount++;
console.log(`đ [DOWNLOAD BAR] Sync not found for ${playlistId} (attempt ${notFoundCount}/${maxNotFoundAttempts})`);
// Only remove after multiple attempts (give it time to start)
if (notFoundCount >= maxNotFoundAttempts) {
console.log(`âšī¸ [DOWNLOAD BAR] Sync not found after ${maxNotFoundAttempts} attempts, removing`);
clearInterval(checkInterval);
removeDiscoverDownload(playlistId);
}
}
} catch (error) {
console.error(`â [DOWNLOAD BAR] Error monitoring ${playlistId}:`, error);
}
}, 2000); // Check every 2 seconds
}
/**
* Remove a download from the bar
*/
function removeDiscoverDownload(playlistId) {
console.log(`đī¸ Removing discover download: ${playlistId}`);
delete discoverDownloads[playlistId];
updateDiscoverDownloadBar();
updateDashboardDownloads();
saveDiscoverDownloadSnapshot(); // Save state after removal
}
/**
* Update the discover download sidebar UI
*/
function updateDiscoverDownloadBar() {
const downloadSidebar = document.getElementById('discover-download-sidebar');
const bubblesContainer = document.getElementById('discover-download-bubbles');
const countElement = document.getElementById('discover-download-count');
console.log(`đ [DOWNLOAD SIDEBAR] Updating sidebar - found elements:`, {
downloadSidebar: !!downloadSidebar,
bubblesContainer: !!bubblesContainer,
countElement: !!countElement
});
if (!downloadSidebar || !bubblesContainer || !countElement) {
console.warn('â ī¸ [DOWNLOAD SIDEBAR] Missing elements, cannot update');
return;
}
const activeDownloads = Object.keys(discoverDownloads);
const count = activeDownloads.length;
console.log(`đ [DOWNLOAD SIDEBAR] Updating with ${count} active downloads`);
// Update count
countElement.textContent = count;
// Show/hide sidebar
if (count === 0) {
console.log(`đī¸ [DOWNLOAD SIDEBAR] No downloads, hiding sidebar`);
downloadSidebar.classList.add('hidden');
return;
} else {
console.log(`đī¸ [DOWNLOAD SIDEBAR] ${count} downloads, showing sidebar`);
downloadSidebar.classList.remove('hidden');
}
// Update bubbles
bubblesContainer.innerHTML = activeDownloads.map(playlistId => {
const download = discoverDownloads[playlistId];
const isCompleted = download.status === 'completed';
const icon = isCompleted ? 'â
' : 'âŗ';
// Use image if available, otherwise gradient background
const imageUrl = download.imageUrl || '';
const backgroundStyle = imageUrl ?
`background-image: url('${imageUrl}');` :
`background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
return `
${escapeHtml(download.name)}
`;
}).join('');
console.log(`đ Updated discover download sidebar: ${count} active downloads`);
// Save snapshot after UI update
saveDiscoverDownloadSnapshot();
}
/**
* Open download modal for a discover playlist
*/
async function openDiscoverDownloadModal(playlistId) {
console.log(`đ [DOWNLOAD BAR] Opening download modal for: ${playlistId}`);
// Check if there's an active download process with modal
let process = activeDownloadProcesses[playlistId];
console.log(`đ [DOWNLOAD BAR] Process found:`, {
exists: !!process,
hasModalElement: !!(process && process.modalElement),
hasModalId: !!(process && process.modalId)
});
if (process) {
// Try modalElement first (album downloads)
if (process.modalElement) {
console.log(`â
[DOWNLOAD BAR] Opening modal via modalElement`);
process.modalElement.style.display = 'flex';
return;
}
// Try modalId (sync downloads)
if (process.modalId) {
const modal = document.getElementById(process.modalId);
if (modal) {
console.log(`â
[DOWNLOAD BAR] Opening modal via modalId: ${process.modalId}`);
modal.style.display = 'flex';
return;
}
}
}
// If no process found, try to rehydrate from backend
console.log(`đ§ [DOWNLOAD BAR] No modal found, attempting to rehydrate from backend...`);
const rehydrated = await rehydrateDiscoverDownloadModal(playlistId);
if (rehydrated) {
console.log(`â
[DOWNLOAD BAR] Successfully rehydrated modal, opening it...`);
// Try again after rehydration
process = activeDownloadProcesses[playlistId];
if (process && process.modalElement) {
process.modalElement.style.display = 'flex';
return;
}
}
// Fallback: show toast
const download = discoverDownloads[playlistId];
if (download) {
console.log(`âšī¸ [DOWNLOAD BAR] No modal found after rehydration attempt, showing toast`);
showToast(`Download: ${download.name} - ${download.status}`, 'info');
} else {
console.warn(`â ī¸ [DOWNLOAD BAR] No download or process found for: ${playlistId}`);
}
}
/**
* Initialize discover download sidebar on page load
*/
function initializeDiscoverDownloadBar() {
console.log('đĩ Initializing discover download sidebar...');
// Start with sidebar hidden (will be shown if downloads exist after hydration)
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
downloadSidebar.classList.add('hidden');
}
}
// --- Discover Download Modal Rehydration ---
async function rehydrateDiscoverDownloadModal(playlistId) {
/**
* Rehydrates a discover download modal from backend process data.
* Fetches tracks from backend API and recreates the modal (user-requested).
*/
try {
console.log(`đ§ [REHYDRATE] Attempting to rehydrate modal for: ${playlistId}`);
// Check if there's an active backend process for this playlist
const batchResponse = await fetch(`/api/download_status/batch`);
if (!batchResponse.ok) {
console.log(`â ī¸ [REHYDRATE] Failed to fetch batch info`);
return false;
}
const batchData = await batchResponse.json();
const batches = batchData.batches || {};
// Find the batch for this playlist (batches is an object with batch_id keys)
let batchId = null;
let batch = null;
for (const [id, batchStatus] of Object.entries(batches)) {
if (batchStatus.playlist_id === playlistId) {
batchId = id;
batch = batchStatus;
break;
}
}
if (!batch || !batchId) {
console.log(`â ī¸ [REHYDRATE] No active batch found for ${playlistId}`);
return false;
}
console.log(`â
[REHYDRATE] Found active batch for ${playlistId}: ${batchId}`, batch);
// Get the download metadata from discoverDownloads
const downloadData = discoverDownloads[playlistId];
if (!downloadData) {
console.log(`â ī¸ [REHYDRATE] No download metadata found for ${playlistId}`);
return false;
}
// Handle album downloads from Recent Releases
if (playlistId.startsWith('discover_album_')) {
const albumId = playlistId.replace('discover_album_', '');
console.log(`đ§ [REHYDRATE] Album download - fetching album ${albumId}...`);
try {
const albumResponse = await fetch(`/api/spotify/album/${albumId}`);
if (!albumResponse.ok) {
console.error(`â [REHYDRATE] Failed to fetch album: ${albumResponse.status}`);
return false;
}
const albumData = await albumResponse.json();
if (!albumData.tracks || albumData.tracks.length === 0) {
console.error(`â [REHYDRATE] No tracks in album`);
return false;
}
// Convert tracks to expected format
const spotifyTracks = albumData.tracks.map(track => {
let artists = track.artists || [];
if (Array.isArray(artists)) {
artists = artists.map(a => a.name || a);
}
return {
id: track.id,
name: track.name,
artists: artists,
album: {
name: albumData.name || downloadData.name.split(' - ')[0],
images: downloadData.imageUrl ? [{ url: downloadData.imageUrl }] : []
},
duration_ms: track.duration_ms || 0
};
});
console.log(`â
[REHYDRATE] Retrieved ${spotifyTracks.length} tracks for album`);
// Create modal
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
// Update process
const process = activeDownloadProcesses[playlistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
subscribeToDownloadBatch(batchId);
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for status updates
startModalDownloadPolling(playlistId);
console.log(`â
[REHYDRATE] Successfully rehydrated album modal with polling`);
return true;
}
return false;
} catch (error) {
console.error(`â [REHYDRATE] Error fetching album:`, error);
return false;
}
}
// Determine API endpoint based on playlist ID
let apiEndpoint;
if (playlistId === 'discover_release_radar') {
apiEndpoint = '/api/discover/release-radar';
} else if (playlistId === 'discover_discovery_weekly') {
apiEndpoint = '/api/discover/discovery-weekly';
} else if (playlistId === 'discover_seasonal_playlist') {
apiEndpoint = '/api/discover/seasonal-playlist';
} else if (playlistId === 'discover_popular_picks') {
apiEndpoint = '/api/discover/popular-picks';
} else if (playlistId === 'discover_hidden_gems') {
apiEndpoint = '/api/discover/hidden-gems';
} else if (playlistId === 'discover_discovery_shuffle') {
apiEndpoint = '/api/discover/discovery-shuffle';
} else if (playlistId === 'discover_familiar_favorites') {
apiEndpoint = '/api/discover/familiar-favorites';
} else if (playlistId === 'build_playlist_custom') {
apiEndpoint = '/api/discover/build-playlist';
} else if (playlistId.startsWith('discover_lb_')) {
// ListenBrainz playlist - fetch from cache
const identifier = playlistId.replace('discover_lb_', '');
const tracks = listenbrainzTracksCache[identifier];
if (!tracks || tracks.length === 0) {
console.log(`â ī¸ [REHYDRATE] No ListenBrainz tracks in cache for ${identifier}`);
return false;
}
// Convert to Spotify format
const spotifyTracks = tracks.map(track => ({
id: track.mbid || `listenbrainz_${track.track_name}_${track.artist_name}`.replace(/[^a-z0-9]/gi, '_'), // Generate ID if missing
name: track.track_name,
artists: [{ name: cleanArtistName(track.artist_name) }], // Proper Spotify format
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0,
mbid: track.mbid
}));
// Create modal and update process
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
const process = activeDownloadProcesses[playlistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
subscribeToDownloadBatch(batchId);
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for status updates
startModalDownloadPolling(playlistId);
console.log(`â
[REHYDRATE] Successfully rehydrated ListenBrainz modal with polling`);
return true;
}
return false;
} else if (playlistId.startsWith('listenbrainz_')) {
// ListenBrainz download from discovery modal - get from backend state
const mbid = playlistId.replace('listenbrainz_', '');
console.log(`đ§ [REHYDRATE] ListenBrainz download - fetching state for MBID: ${mbid}`);
try {
// Fetch ListenBrainz state from backend
const stateResponse = await fetch(`/api/listenbrainz/state/${mbid}`);
if (!stateResponse.ok) {
console.log(`â ī¸ [REHYDRATE] Failed to fetch ListenBrainz state`);
return false;
}
const stateData = await stateResponse.json();
if (!stateData || !stateData.discovery_results) {
console.log(`â ī¸ [REHYDRATE] No discovery results in ListenBrainz state`);
return false;
}
// Convert discovery results to Spotify tracks
const spotifyTracks = stateData.discovery_results
.filter(result => result.spotify_data)
.map(result => {
const track = result.spotify_data;
// Ensure artists is in proper Spotify format: [{name: ...}]
let artistsArray = [];
if (track.artists && Array.isArray(track.artists)) {
artistsArray = track.artists.map(artist => {
if (typeof artist === 'string') {
return { name: artist };
} else if (artist && artist.name) {
return { name: artist.name };
} else {
return { name: String(artist || 'Unknown Artist') };
}
});
} else if (track.artists && typeof track.artists === 'string') {
artistsArray = [{ name: track.artists }];
} else {
artistsArray = [{ name: 'Unknown Artist' }];
}
return {
id: track.id,
name: track.name,
artists: artistsArray,
album: track.album || { name: 'Unknown Album', images: [] },
duration_ms: track.duration_ms || 0,
external_urls: track.external_urls || {}
};
});
if (spotifyTracks.length === 0) {
console.log(`â ī¸ [REHYDRATE] No Spotify tracks in ListenBrainz discovery results`);
return false;
}
console.log(`â
[REHYDRATE] Retrieved ${spotifyTracks.length} tracks from ListenBrainz state`);
// Create modal and update process
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
const process = activeDownloadProcesses[playlistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
subscribeToDownloadBatch(batchId);
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for status updates
startModalDownloadPolling(playlistId);
console.log(`â
[REHYDRATE] Successfully rehydrated ListenBrainz download modal with polling`);
return true;
}
return false;
} catch (error) {
console.error(`â [REHYDRATE] Error fetching ListenBrainz state:`, error);
return false;
}
} else {
console.error(`â [REHYDRATE] Unknown discover playlist type: ${playlistId}`);
return false;
}
// Fetch tracks from API
console.log(`đĄ [REHYDRATE] Fetching tracks from ${apiEndpoint}...`);
const response = await fetch(apiEndpoint);
if (!response.ok) {
console.error(`â [REHYDRATE] Failed to fetch tracks: ${response.status}`);
return false;
}
const data = await response.json();
if (!data.success || !data.tracks) {
console.error(`â [REHYDRATE] Invalid track data:`, data);
return false;
}
const tracks = data.tracks;
console.log(`â
[REHYDRATE] Retrieved ${tracks.length} tracks`);
// Transform tracks to Spotify format
const spotifyTracks = tracks.map(track => {
let spotifyTrack;
if (track.track_data_json) {
spotifyTrack = track.track_data_json;
} else {
spotifyTrack = {
id: track.spotify_track_id,
name: track.track_name,
artists: [{ name: track.artist_name }],
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0
};
}
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
}
return spotifyTrack;
});
// Create the modal
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
// Update process with batch info
const process = activeDownloadProcesses[playlistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
subscribeToDownloadBatch(batchId);
// Update button states
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for status updates
startModalDownloadPolling(playlistId);
// Don't hide the modal - user clicked to open it
console.log(`â
[REHYDRATE] Successfully rehydrated modal for ${downloadData.name} with polling`);
return true;
} else {
console.error(`â [REHYDRATE] Failed to find rehydrated process for ${playlistId}`);
return false;
}
} catch (error) {
console.error(`â [REHYDRATE] Error rehydrating discover download modal:`, error);
return false;
}
}
// --- Discover Download Snapshot System ---
let discoverSnapshotSaveTimeout = null; // Debounce snapshot saves
async function saveDiscoverDownloadSnapshot() {
/**
* Saves current discoverDownloads state to backend for persistence.
* Debounced to prevent excessive backend calls.
*/
// Clear any existing timeout
if (discoverSnapshotSaveTimeout) {
clearTimeout(discoverSnapshotSaveTimeout);
}
// Debounce the actual save
discoverSnapshotSaveTimeout = setTimeout(async () => {
try {
const downloadCount = Object.keys(discoverDownloads).length;
// Don't save empty state
if (downloadCount === 0) {
console.log('đ¸ Skipping discover snapshot save - no downloads to save');
return;
}
console.log(`đ¸ Saving discover download snapshot: ${downloadCount} downloads`);
// Prepare snapshot data (clean format)
const cleanDownloads = {};
for (const [playlistId, downloadData] of Object.entries(discoverDownloads)) {
cleanDownloads[playlistId] = {
name: downloadData.name,
type: downloadData.type,
status: downloadData.status,
virtualPlaylistId: downloadData.virtualPlaylistId,
imageUrl: downloadData.imageUrl,
startTime: downloadData.startTime instanceof Date ? downloadData.startTime.toISOString() : downloadData.startTime
};
}
const response = await fetch('/api/discover_downloads/snapshot', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
downloads: cleanDownloads
})
});
const data = await response.json();
if (data.success) {
console.log(`â
Discover download snapshot saved: ${downloadCount} downloads`);
} else {
console.error('â Failed to save discover download snapshot:', data.error);
}
} catch (error) {
console.error('â Error saving discover download snapshot:', error);
}
}, 1000); // 1 second debounce
}
async function hydrateDiscoverDownloadsFromSnapshot() {
/**
* Hydrates discover downloads from backend snapshot with live status.
* Called on page load to restore download state.
*/
try {
console.log('đ Loading discover download snapshot from backend...');
const response = await fetch('/api/discover_downloads/hydrate');
const data = await response.json();
if (!data.success) {
console.error('â Failed to load discover download snapshot:', data.error);
return;
}
const downloads = data.downloads || {};
const stats = data.stats || {};
console.log(`đ Loaded discover snapshot: ${stats.total_downloads || 0} downloads, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`);
if (Object.keys(downloads).length === 0) {
console.log('âšī¸ No discover downloads to hydrate');
return;
}
// Clear existing state
discoverDownloads = {};
// Restore discoverDownloads with hydrated data
for (const [playlistId, downloadData] of Object.entries(downloads)) {
discoverDownloads[playlistId] = {
name: downloadData.name,
type: downloadData.type,
status: downloadData.status, // Live status from backend
virtualPlaylistId: downloadData.virtualPlaylistId,
imageUrl: downloadData.imageUrl,
startTime: new Date(downloadData.startTime)
};
console.log(`đ Hydrated download: ${downloadData.name} (${downloadData.status})`);
// Start monitoring for any in-progress downloads
if (downloadData.status === 'in_progress') {
console.log(`đĄ Starting monitoring for: ${downloadData.name}`);
monitorDiscoverDownload(playlistId);
}
}
// Don't update UI here - it will be updated when user navigates to discover page
// This allows hydration to work even if page loads on a different tab
const totalDownloads = Object.keys(discoverDownloads).length;
console.log(`â
Successfully hydrated ${totalDownloads} discover downloads (UI will update on discover page navigation)`);
} catch (error) {
console.error('â Error hydrating discover downloads from snapshot:', error);
}
}
// Initialize on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeDiscoverDownloadBar);
} else {
initializeDiscoverDownloadBar();
}
// ============================================================================
// ââ SoulSync Discover Sync Tab âââââââââââââââââââââââââââââââââââââââââ
async function loadDiscoverSyncPlaylists() {
if (discoverSyncPlaylistsLoaded) return;
discoverSyncPlaylistsLoaded = true;
const container = document.getElementById('discover-sync-playlist-container');
if (!container) return;
container.innerHTML = '
Loading Discover playlists...
';
try {
const response = await fetch('/api/discover/synced-playlists');
const data = await response.json();
if (!data.success || !data.playlists || data.playlists.length === 0) {
container.innerHTML = '
No Discover playlists available. Visit the Discover page to generate playlists first.
';
return;
}
container.innerHTML = '';
// Show source info and empty-state hint if no playlists have data
if (!data.has_data) {
const hint = document.createElement('div');
hint.className = 'discover-sync-empty-hint';
hint.innerHTML = `
Your Discover playlists don't have any tracks yet.
Go to the Discover page and let it build your playlist pool â it uses your ${data.source_label || 'configured source'} data and watchlist to generate personalized playlists.
`;
container.appendChild(hint);
}
data.playlists.forEach(playlist => {
renderDiscoverSyncCard(playlist, container, data.source_label || data.source);
// Resume polling if there's an active batch for this playlist
if (playlist.active_batch_id && playlist.sync_status === 'syncing') {
const btn = document.getElementById(`discover-sync-btn-${playlist.type}`);
if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; }
pollDiscoverBatchFromTab(playlist.type, playlist.active_batch_id, playlist.name);
}
});
// Also fetch ListenBrainz playlists and add them
try {
// Fetch saved auto-update settings so LB toggles persist across restarts
let lbAutoSettings = {};
try {
const settingsRes = await fetch('/api/discover/auto-update');
if (settingsRes.ok) {
const settingsData = await settingsRes.json();
if (settingsData.success) lbAutoSettings = settingsData.settings || {};
}
} catch (_) {}
const lbRes = await fetch('/api/discover/listenbrainz/created-for');
if (lbRes.ok) {
const lbData = await lbRes.json();
if (lbData.success && lbData.playlists && lbData.playlists.length > 0) {
// Fetch sync history once for all LB playlists
let historyEntries = [];
try {
const histRes = await fetch('/api/sync/history?source=discover&limit=50');
if (histRes.ok) {
const histData = await histRes.json();
historyEntries = histData.entries || [];
}
} catch (_) {}
// Deduplicate by base name â only show the latest of each type
const seen = new Map();
for (const p of lbData.playlists) {
const pl = p.playlist || p;
const rawTitle = pl.title || 'ListenBrainz Playlist';
// Strip ", week of YYYY-MM-DD ..." suffix for a stable display name
const baseName = rawTitle.replace(/,\s*week of .+$/i, '').trim();
// Keep only the first (latest) for each base name
if (!seen.has(baseName)) seen.set(baseName, { pl, rawTitle, baseName });
}
for (const { pl, rawTitle, baseName } of seen.values()) {
const identifier = pl.identifier || '';
const mbid = identifier.split('/').pop();
const trackCount = pl.track_count || (pl.track || []).length;
// Determine icon from title
let icon = 'đ§ ';
if (rawTitle.toLowerCase().includes('jam')) icon = 'đ¸';
else if (rawTitle.toLowerCase().includes('explor')) icon = 'đ';
const lbType = `listenbrainz_${mbid}`;
// Check sync history for this playlist by matching the base name
let syncStatus = 'never';
let lastSynced = null;
let matchedTracks = 0;
let totalSyncTracks = 0;
for (const entry of historyEntries) {
const eName = entry.playlist_name || '';
if (eName === baseName || eName.startsWith(baseName)) {
syncStatus = 'synced';
lastSynced = entry.completed_at || entry.started_at || entry.created_at;
matchedTracks = entry.tracks_found || 0;
totalSyncTracks = entry.total_tracks || 0;
break;
}
}
renderDiscoverSyncCard({
type: lbType,
name: baseName,
description: '',
icon: icon,
track_count: trackCount,
sync_status: syncStatus,
last_synced: lastSynced,
matched_tracks: matchedTracks,
total_sync_tracks: totalSyncTracks,
auto_update: !!lbAutoSettings[lbType],
virtual_id: `discover_listenbrainz_${mbid}`,
_lb_mbid: mbid,
}, container, 'ListenBrainz');
}
}
}
} catch (lbErr) {
console.warn('Could not load ListenBrainz playlists for discover sync tab:', lbErr);
}
} catch (error) {
console.error('Error loading discover sync playlists:', error);
container.innerHTML = '
Error loading Discover playlists.
';
}
}
function renderDiscoverSyncCard(playlist, container, sourceLabel) {
const card = document.createElement('div');
const isEmpty = playlist.track_count === 0;
card.className = `discover-sync-card${isEmpty ? ' discover-sync-card-empty' : ''}`;
card.id = `discover-sync-card-${playlist.type}`;
const lastSyncedText = playlist.last_synced
? `Last synced ${timeAgo(playlist.last_synced)}`
: 'Never synced';
const statusClass = playlist.sync_status === 'syncing' ? 'syncing' :
playlist.sync_status === 'synced' ? 'synced' : 'not-synced';
let statusText = playlist.sync_status === 'syncing' ? 'Syncing...' :
playlist.sync_status === 'synced' ? 'Synced' : 'Not synced';
// Show matched/total counts if available (only when matched > 0, meaning completion was recorded)
if (playlist.sync_status === 'synced' && playlist.matched_tracks > 0 && playlist.total_sync_tracks > 0) {
statusText = `Synced ${playlist.matched_tracks}/${playlist.total_sync_tracks}`;
}
const trackLabel = isEmpty ? 'No tracks yet' : `${playlist.track_count} tracks`;
card.innerHTML = `
${playlist.icon}
${playlist.name}
${sourceLabel || 'unknown'}
\u00b7
${trackLabel}
\u00b7
${statusText}
\u00b7
${lastSyncedText}
`;
// Make the icon + info area clickable to view tracks
if (!isEmpty) {
const clickArea = card.querySelector('.discover-sync-card-info');
const iconArea = card.querySelector('.discover-sync-card-icon');
[clickArea, iconArea].forEach(el => {
el.style.cursor = 'pointer';
el.addEventListener('click', () => openDiscoverPlaylistModal(playlist.type, playlist.name, playlist.icon));
});
}
container.appendChild(card);
}
async function toggleDiscoverAutoUpdate(playlistType, enabled) {
try {
const response = await fetch('/api/discover/auto-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlist_type: playlistType, enabled: enabled })
});
const data = await response.json();
if (data.success) {
showToast(`Auto-update ${enabled ? 'enabled' : 'disabled'} for ${playlistType.replace(/_/g, ' ')}`, 'success');
} else {
showToast(`Failed to update setting: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error toggling auto-update:', error);
showToast('Failed to update setting', 'error');
}
}
const _discoverSyncQueue = [];
let _discoverSyncRunning = false;
async function syncDiscoverPlaylistFromTab(playlistType, playlistName, forceDownload) {
// Read force-download toggle if not explicitly passed
if (forceDownload === undefined) {
const fdToggle = document.getElementById(`discover-force-dl-${playlistType}`);
forceDownload = fdToggle ? fdToggle.checked : false;
}
// Serialize sync operations to avoid concurrent backend contention
return new Promise((resolve) => {
_discoverSyncQueue.push({ playlistType, playlistName, forceDownload, resolve });
_processDiscoverSyncQueue();
});
}
async function _processDiscoverSyncQueue() {
if (_discoverSyncRunning || _discoverSyncQueue.length === 0) return;
_discoverSyncRunning = true;
const { playlistType, playlistName, forceDownload, resolve } = _discoverSyncQueue.shift();
try {
await _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload);
} finally {
_discoverSyncRunning = false;
resolve();
_processDiscoverSyncQueue();
}
}
async function _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload) {
const btn = document.getElementById(`discover-sync-btn-${playlistType}`);
if (btn) {
btn.disabled = true;
btn.textContent = 'Syncing...';
}
try {
let tracksResponse;
// Use unified URL helper (handles ListenBrainz + standard discover types)
const apiUrl = _discoverPlaylistApiUrl(playlistType);
if (apiUrl) {
tracksResponse = await fetch(apiUrl);
}
let tracks = [];
if (tracksResponse && tracksResponse.ok) {
const data = await tracksResponse.json();
tracks = data.tracks || [];
}
if (!tracks.length) {
showToast(`No tracks available for ${playlistName}. Visit the Discover page first.`, 'warning');
if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; }
return;
}
const syncTracks = tracks.map(track => {
if (track.track_data_json) {
const t = track.track_data_json;
if (t.artists && Array.isArray(t.artists)) {
t.artists = t.artists.map(a => a.name || a);
}
return t;
}
return {
id: track.spotify_track_id || track.track_id || '',
name: track.track_name || track.name || '',
artists: [track.artist_name || 'Unknown Artist'],
album: track.album_name || '',
duration_ms: track.duration_ms || 0,
image_url: track.album_cover_url || track.image_url || ''
};
});
const virtualPlaylistId = `discover_${playlistType}`;
// Use the download batch endpoint directly so the batch is labeled
// as "Discover" instead of going through sync â wishlist â "Wishlist" batch.
const bodyPayload = {
tracks: syncTracks,
playlist_name: playlistName
};
if (forceDownload) bodyPayload.force_download_all = true;
const batchResponse = await fetch(`/api/playlists/${virtualPlaylistId}/start-missing-process`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bodyPayload)
});
const result = await batchResponse.json();
if (result.success) {
const forceLabel = forceDownload ? ' (force download)' : '';
showToast(`Downloading ${playlistName}${forceLabel} (${syncTracks.length} tracks)...`, 'info');
const card = document.getElementById(`discover-sync-card-${playlistType}`);
if (card) {
const statusEl = card.querySelector('.discover-sync-status');
if (statusEl) {
statusEl.className = 'discover-sync-status syncing';
statusEl.textContent = 'Downloading...';
}
}
// Poll the download batch status
pollDiscoverBatchFromTab(playlistType, result.batch_id, playlistName);
} else {
showToast(`Download failed: ${result.error || 'Unknown error'}`, 'error');
if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; }
}
} catch (error) {
console.error(`Error syncing ${playlistName}:`, error);
showToast(`Failed to sync ${playlistName}`, 'error');
if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; }
}
}
function pollDiscoverSyncFromTab(playlistType, virtualPlaylistId, playlistName) {
const pollInterval = setInterval(async () => {
try {
const resp = await fetch(`/api/sync/status/${virtualPlaylistId}`);
if (!resp.ok) { clearInterval(pollInterval); return; }
const data = await resp.json();
if (data.status === 'finished' || data.status === 'error') {
clearInterval(pollInterval);
const btn = document.getElementById(`discover-sync-btn-${playlistType}`);
if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; }
const card = document.getElementById(`discover-sync-card-${playlistType}`);
if (card) {
const statusEl = card.querySelector('.discover-sync-status');
if (statusEl) {
if (data.status === 'finished') {
const progress = data.progress || data.result || {};
const matched = progress.matched_tracks || 0;
const total = progress.total_tracks || 0;
statusEl.className = 'discover-sync-status synced';
statusEl.textContent = matched > 0 && total > 0 ? `Synced ${matched}/${total}` : 'Synced';
} else {
statusEl.className = 'discover-sync-status not-synced';
statusEl.textContent = 'Failed';
}
}
const lastSyncedEl = card.querySelector('.discover-sync-last-synced');
if (lastSyncedEl && data.status === 'finished') {
lastSyncedEl.textContent = 'Last synced just now';
}
}
if (data.status === 'finished') {
showToast(`${playlistName} synced successfully!`, 'success');
} else {
showToast(`${playlistName} sync failed`, 'error');
}
}
} catch (error) {
clearInterval(pollInterval);
}
}, 2000);
}
function pollDiscoverBatchFromTab(playlistType, batchId, playlistName) {
const pollInterval = setInterval(async () => {
try {
const resp = await fetch(`/api/playlists/${batchId}/download_status`);
if (!resp.ok) { clearInterval(pollInterval); return; }
const data = await resp.json();
const phase = data.phase || data.status;
if (phase === 'complete' || phase === 'error' || phase === 'cancelled') {
clearInterval(pollInterval);
const btn = document.getElementById(`discover-sync-btn-${playlistType}`);
if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; }
// Extract matched/total from analysis_results
const analysisResults = data.analysis_results || [];
const totalTracks = analysisResults.length;
const matchedTracks = analysisResults.filter(r => r.found).length;
const tasks = data.tasks || [];
const downloaded = tasks.filter(t => t.status === 'completed').length;
const failed = tasks.filter(t => t.status === 'failed' || t.status === 'not_found').length;
const card = document.getElementById(`discover-sync-card-${playlistType}`);
const syncedCount = matchedTracks + downloaded;
if (card) {
const statusEl = card.querySelector('.discover-sync-status');
if (statusEl) {
statusEl.className = `discover-sync-status ${phase === 'complete' ? 'synced' : 'not-synced'}`;
if (phase === 'complete' && totalTracks > 0) {
statusEl.textContent = `Synced ${syncedCount}/${totalTracks}`;
} else {
statusEl.textContent = phase === 'complete' ? 'Synced' : (phase === 'cancelled' ? 'Cancelled' : 'Failed');
}
}
const lastSyncedEl = card.querySelector('.discover-sync-last-synced');
if (lastSyncedEl && phase === 'complete') {
lastSyncedEl.textContent = 'Last synced just now';
}
}
if (phase === 'complete') {
if (totalTracks > 0) {
const missing = totalTracks - syncedCount;
let msg = `${playlistName}: ${syncedCount}/${totalTracks} in library`;
if (downloaded > 0) msg += `, ${downloaded} downloaded`;
if (failed > 0) msg += `, ${failed} failed`;
if (missing === 0) msg += ' - all owned!';
showToast(msg, 'success');
} else {
showToast(`${playlistName} download complete!`, 'success');
}
} else if (phase !== 'cancelled') {
showToast(`${playlistName} download failed`, 'error');
}
}
} catch (error) {
clearInterval(pollInterval);
}
}, 3000);
}
/**
* Map a discover playlist type to its API endpoint for fetching tracks.
*/
function _discoverPlaylistApiUrl(playlistType) {
// ListenBrainz playlists
if (playlistType.startsWith('listenbrainz_')) {
const mbid = playlistType.replace('listenbrainz_', '');
return `/api/discover/listenbrainz/playlist/${mbid}`;
}
const map = {
release_radar: '/api/discover/release-radar',
discovery_weekly: '/api/discover/weekly',
seasonal_playlist: '/api/discover/seasonal/current-playlist',
popular_picks: '/api/discover/personalized/popular-picks',
hidden_gems: '/api/discover/personalized/hidden-gems',
discovery_shuffle: '/api/discover/personalized/discovery-shuffle',
familiar_favorites: '/api/discover/personalized/familiar-favorites',
};
return map[playlistType] || null;
}
/**
* Open a modal showing all tracks in a Discover playlist (mirrored-modal style).
*/
async function openDiscoverPlaylistModal(playlistType, playlistName, icon) {
const apiUrl = _discoverPlaylistApiUrl(playlistType);
if (!apiUrl) { showToast('Unknown playlist type', 'error'); return; }
showLoadingOverlay(`Loading ${playlistName}...`);
try {
const res = await fetch(apiUrl);
const data = await res.json();
const tracks = data.tracks || [];
hideLoadingOverlay();
if (!tracks.length) {
showToast(`No tracks in ${playlistName}. Visit the Discover page first.`, 'warning');
return;
}
// Remove any existing modal
const old = document.getElementById('discover-playlist-modal');
if (old) old.remove();
const overlay = document.createElement('div');
overlay.id = 'discover-playlist-modal';
overlay.className = 'mirrored-modal-overlay';
const trackRows = tracks.map((t, idx) => {
const name = t.track_name || t.name || '';
const artist = t.artist_name || (t.artists ? (Array.isArray(t.artists) ? t.artists.map(a => a.name || a).join(', ') : t.artists) : '');
const album = t.album_name || t.album || '';
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
const coverUrl = t.album_cover_url || '';
const coverHtml = coverUrl
? `
`
: `
`;
return `
${idx + 1}
${coverHtml}
${_esc(name)}
${_esc(artist)}
${_esc(album)}
${dur}
`;
}).join('');
overlay.innerHTML = `
`;
overlay.addEventListener('click', e => { if (e.target === overlay) closeDiscoverPlaylistModal(); });
document.body.appendChild(overlay);
} catch (err) {
hideLoadingOverlay();
showToast(`Error loading ${playlistName}: ${err.message}`, 'error');
}
}
function closeDiscoverPlaylistModal() {
const m = document.getElementById('discover-playlist-modal');
if (m) m.remove();
}