|
|
// == SYNC PAGE SPOTIFY FUNCTIONALITY ==
|
|
|
// ===========================================
|
|
|
|
|
|
async function loadSyncData() {
|
|
|
// This is called when the sync page is navigated to.
|
|
|
// Load server playlists first (default active tab)
|
|
|
if (!window._serverPlaylistsLoaded) {
|
|
|
window._serverPlaylistsLoaded = true;
|
|
|
loadServerPlaylists(); // Don't await — load in background
|
|
|
}
|
|
|
|
|
|
if (!spotifyPlaylistsLoaded) {
|
|
|
await loadSpotifyPlaylists();
|
|
|
}
|
|
|
|
|
|
// Load YouTube playlists from backend (always refresh to get latest state)
|
|
|
await loadYouTubePlaylistsFromBackend();
|
|
|
|
|
|
// Render saved URL histories for YouTube, Deezer, Spotify Link tabs
|
|
|
initUrlHistories();
|
|
|
}
|
|
|
|
|
|
async function ensureBeatportContentLoaded() {
|
|
|
if (beatportContentState.loaded) {
|
|
|
showBeatportDownloadsSection();
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
if (beatportContentState.loadingPromise) {
|
|
|
return beatportContentState.loadingPromise;
|
|
|
}
|
|
|
|
|
|
beatportContentState.abortController = new AbortController();
|
|
|
beatportContentState.loadingPromise = (async () => {
|
|
|
try {
|
|
|
console.log('🎧 Lazy-loading Beatport content...');
|
|
|
|
|
|
await hydrateBeatportBubblesFromSnapshot();
|
|
|
throwIfBeatportLoadAborted();
|
|
|
await loadBeatportChartsFromBackend();
|
|
|
throwIfBeatportLoadAborted();
|
|
|
|
|
|
initializeBeatportRebuildSlider();
|
|
|
initializeBeatportReleasesSlider();
|
|
|
initializeBeatportHypePicksSlider();
|
|
|
initializeBeatportChartsSlider();
|
|
|
initializeBeatportDJSlider();
|
|
|
throwIfBeatportLoadAborted();
|
|
|
await Promise.all([
|
|
|
loadBeatportTop10Lists(),
|
|
|
loadBeatportTop10Releases()
|
|
|
]);
|
|
|
throwIfBeatportLoadAborted();
|
|
|
showBeatportDownloadsSection();
|
|
|
|
|
|
beatportContentState.loaded = true;
|
|
|
console.log('✅ Beatport content loaded');
|
|
|
return true;
|
|
|
} catch (error) {
|
|
|
if (error && error.name === 'AbortError') {
|
|
|
console.log('⏹ Beatport content load aborted');
|
|
|
return false;
|
|
|
}
|
|
|
console.error('❌ Error loading Beatport content:', error);
|
|
|
return false;
|
|
|
} finally {
|
|
|
beatportContentState.loadingPromise = null;
|
|
|
if (beatportContentState.abortController && beatportContentState.abortController.signal.aborted) {
|
|
|
beatportContentState.abortController = null;
|
|
|
}
|
|
|
}
|
|
|
})();
|
|
|
|
|
|
return beatportContentState.loadingPromise;
|
|
|
}
|
|
|
|
|
|
async function checkForActiveProcesses() {
|
|
|
try {
|
|
|
const response = await fetch('/api/active-processes');
|
|
|
if (!response.ok) return;
|
|
|
|
|
|
const data = await response.json();
|
|
|
const processes = data.active_processes || [];
|
|
|
|
|
|
if (processes.length > 0) {
|
|
|
console.log(`🔄 Found ${processes.length} active process(es) from backend. Rehydrating UI...`);
|
|
|
|
|
|
// Separate download batch processes from YouTube playlist processes
|
|
|
const downloadProcesses = processes.filter(p => p.type === 'batch');
|
|
|
const youtubeProcesses = processes.filter(p => p.type === 'youtube_playlist');
|
|
|
|
|
|
console.log(`📊 Process breakdown: ${downloadProcesses.length} download batches, ${youtubeProcesses.length} YouTube playlists`);
|
|
|
|
|
|
// Rehydrate download modal processes (existing Spotify system)
|
|
|
for (const processInfo of downloadProcesses) {
|
|
|
if (!activeDownloadProcesses[processInfo.playlist_id]) {
|
|
|
rehydrateModal(processInfo);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Note: YouTube playlists are handled by loadYouTubePlaylistsFromBackend() and rehydrateYouTubePlaylist()
|
|
|
// in loadSyncData(), which provides more complete data than active processes and handles download modal rehydration.
|
|
|
console.log(`ℹ️ Skipping ${youtubeProcesses.length} YouTube playlists - handled by full backend loading`);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error('Failed to check for active processes:', error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function rehydrateArtistAlbumModal(virtualPlaylistId, playlistName, batchId) {
|
|
|
/**
|
|
|
* Rehydrates an artist album download modal from backend process data.
|
|
|
* Extracts artist/album info from virtual playlist ID and recreates the modal.
|
|
|
*/
|
|
|
try {
|
|
|
console.log(`💧 Rehydrating artist album modal: ${virtualPlaylistId} (${playlistName})`);
|
|
|
|
|
|
// Extract artist_id and album_id from virtualPlaylistId format: artist_album_[artist_id]_[album_id]
|
|
|
const parts = virtualPlaylistId.split('_');
|
|
|
if (parts.length < 4 || parts[0] !== 'artist' || parts[1] !== 'album') {
|
|
|
console.error(`❌ Invalid virtual playlist ID format: ${virtualPlaylistId}`);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const artistId = parts[2];
|
|
|
const albumId = parts.slice(3).join('_'); // Handle album IDs that might contain underscores
|
|
|
|
|
|
console.log(`🔍 Extracted from virtual playlist: artistId=${artistId}, albumId=${albumId}`);
|
|
|
|
|
|
// Fetch the album tracks to get proper artist and album data
|
|
|
try {
|
|
|
const rehydrateProcess = activeDownloadProcesses[virtualPlaylistId];
|
|
|
const albumSource = rehydrateProcess?.source || rehydrateProcess?.artist?.source || rehydrateProcess?.album?.source || null;
|
|
|
const artistName = rehydrateProcess?.artist?.name || '';
|
|
|
const params = new URLSearchParams({
|
|
|
name: playlistName || '',
|
|
|
artist: artistName
|
|
|
});
|
|
|
if (albumSource) {
|
|
|
params.set('source', albumSource);
|
|
|
}
|
|
|
|
|
|
const response = await fetch(`/api/album/${albumId}/tracks?${params}`);
|
|
|
const data = await response.json();
|
|
|
|
|
|
if (!data.success || !data.album || !data.tracks) {
|
|
|
console.error('❌ Failed to fetch album data for rehydration:', data.error);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const album = data.album;
|
|
|
const tracks = data.tracks;
|
|
|
const source = data.source || tracks[0]?._source || albumSource || null;
|
|
|
|
|
|
// Extract artist info from the first track (all tracks should have same artist)
|
|
|
const artist = {
|
|
|
id: artistId,
|
|
|
name: tracks[0].artists[0], // Use first artist name from first track
|
|
|
source
|
|
|
};
|
|
|
if (source) {
|
|
|
album.source = source;
|
|
|
}
|
|
|
|
|
|
console.log(`✅ Retrieved album data: "${album.name}" by ${artist.name} (${tracks.length} tracks)`);
|
|
|
|
|
|
// Create the modal using the same function as normal artist album downloads
|
|
|
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, tracks, album, artist);
|
|
|
|
|
|
// Update the rehydrated process with batch info and hide modal for background rehydration
|
|
|
const process = activeDownloadProcesses[virtualPlaylistId];
|
|
|
if (process) {
|
|
|
process.status = 'running';
|
|
|
process.batchId = batchId;
|
|
|
subscribeToDownloadBatch(batchId);
|
|
|
|
|
|
// Update button states to reflect running status
|
|
|
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
|
|
|
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
|
|
|
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${virtualPlaylistId}`);
|
|
|
if (beginBtn) beginBtn.style.display = 'none';
|
|
|
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
|
|
if (wishlistBtn) wishlistBtn.style.display = 'none';
|
|
|
|
|
|
// Hide the modal - this is background rehydration, not user-requested
|
|
|
if (process.modalElement) {
|
|
|
process.modalElement.style.display = 'none';
|
|
|
console.log(`🔍 Hiding rehydrated modal for background processing: ${album.name}`);
|
|
|
}
|
|
|
|
|
|
console.log(`✅ Rehydrated artist album modal: ${artist.name} - ${album.name}`);
|
|
|
} else {
|
|
|
console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`);
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error(`❌ Error fetching album data for rehydration:`, error);
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error(`❌ Error rehydrating artist album modal:`, error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function rehydrateDiscoverPlaylistModal(virtualPlaylistId, playlistName, batchId) {
|
|
|
/**
|
|
|
* Rehydrates a discover playlist download modal from backend process data.
|
|
|
* Fetches tracks from the appropriate discover API endpoint and recreates the modal.
|
|
|
*/
|
|
|
try {
|
|
|
console.log(`💧 Rehydrating discover playlist modal: ${virtualPlaylistId} (${playlistName})`);
|
|
|
|
|
|
// Handle album downloads from Recent Releases
|
|
|
if (virtualPlaylistId.startsWith('discover_album_')) {
|
|
|
const albumId = virtualPlaylistId.replace('discover_album_', '');
|
|
|
console.log(`💧 Album download - fetching album ${albumId}...`);
|
|
|
|
|
|
try {
|
|
|
const albumResponse = await fetch(`/api/spotify/album/${albumId}`);
|
|
|
if (!albumResponse.ok) {
|
|
|
console.error(`❌ Failed to fetch album: ${albumResponse.status}`);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const albumData = await albumResponse.json();
|
|
|
if (!albumData.tracks || albumData.tracks.length === 0) {
|
|
|
console.error(`❌ No tracks in album`);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 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 || playlistName.split(' - ')[0],
|
|
|
images: albumData.images || []
|
|
|
},
|
|
|
duration_ms: track.duration_ms || 0
|
|
|
};
|
|
|
});
|
|
|
|
|
|
console.log(`✅ Retrieved ${spotifyTracks.length} tracks for album`);
|
|
|
|
|
|
// Create modal
|
|
|
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
|
|
|
|
|
|
// Update process
|
|
|
const process = activeDownloadProcesses[virtualPlaylistId];
|
|
|
if (process) {
|
|
|
process.status = 'running';
|
|
|
process.batchId = batchId;
|
|
|
subscribeToDownloadBatch(batchId);
|
|
|
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
|
|
|
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
|
|
|
if (beginBtn) beginBtn.style.display = 'none';
|
|
|
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
|
|
|
|
|
// Hide modal for background rehydration
|
|
|
if (process.modalElement) {
|
|
|
process.modalElement.style.display = 'none';
|
|
|
console.log(`🔍 Hiding rehydrated modal for background processing: ${playlistName}`);
|
|
|
}
|
|
|
|
|
|
console.log(`✅ Rehydrated album modal: ${playlistName}`);
|
|
|
}
|
|
|
return;
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error(`❌ Error fetching album:`, error);
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Determine API endpoint based on playlist ID
|
|
|
let apiEndpoint;
|
|
|
if (virtualPlaylistId === 'discover_release_radar') {
|
|
|
apiEndpoint = '/api/discover/release-radar';
|
|
|
} else if (virtualPlaylistId === 'discover_discovery_weekly') {
|
|
|
apiEndpoint = '/api/discover/discovery-weekly';
|
|
|
} else if (virtualPlaylistId === 'discover_seasonal_playlist') {
|
|
|
apiEndpoint = '/api/discover/seasonal-playlist';
|
|
|
} else if (virtualPlaylistId === 'discover_popular_picks') {
|
|
|
apiEndpoint = '/api/discover/popular-picks';
|
|
|
} else if (virtualPlaylistId === 'discover_hidden_gems') {
|
|
|
apiEndpoint = '/api/discover/hidden-gems';
|
|
|
} else if (virtualPlaylistId === 'discover_discovery_shuffle') {
|
|
|
apiEndpoint = '/api/discover/discovery-shuffle';
|
|
|
} else if (virtualPlaylistId === 'build_playlist_custom') {
|
|
|
apiEndpoint = '/api/discover/build-playlist';
|
|
|
} else if (virtualPlaylistId.startsWith('discover_lb_')) {
|
|
|
console.log(`💧 ListenBrainz playlist - skipping (no automatic rehydration for ListenBrainz)`);
|
|
|
return;
|
|
|
} else {
|
|
|
console.error(`❌ Unknown discover playlist type: ${virtualPlaylistId}`);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Fetch tracks from API
|
|
|
console.log(`📡 Fetching tracks from ${apiEndpoint}...`);
|
|
|
const response = await fetch(apiEndpoint);
|
|
|
if (!response.ok) {
|
|
|
console.error(`❌ Failed to fetch discover playlist data: ${response.status}`);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const data = await response.json();
|
|
|
if (!data.success || !data.tracks) {
|
|
|
console.error(`❌ Invalid discover playlist data:`, data);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const tracks = data.tracks;
|
|
|
console.log(`✅ Retrieved ${tracks.length} tracks for ${playlistName}`);
|
|
|
|
|
|
// Transform tracks to format expected by download modal (same as openDownloadModalForDiscoverPlaylist)
|
|
|
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 the modal using the same function as normal discover downloads
|
|
|
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
|
|
|
|
|
|
// Update the rehydrated process with batch info and hide modal for background rehydration
|
|
|
const process = activeDownloadProcesses[virtualPlaylistId];
|
|
|
if (process) {
|
|
|
process.status = 'running';
|
|
|
process.batchId = batchId;
|
|
|
subscribeToDownloadBatch(batchId);
|
|
|
|
|
|
// Update button states to reflect running status
|
|
|
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
|
|
|
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
|
|
|
if (beginBtn) beginBtn.style.display = 'none';
|
|
|
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
|
|
|
|
|
// Hide the modal - this is background rehydration, not user-requested
|
|
|
if (process.modalElement) {
|
|
|
process.modalElement.style.display = 'none';
|
|
|
console.log(`🔍 Hiding rehydrated modal for background processing: ${playlistName}`);
|
|
|
}
|
|
|
|
|
|
console.log(`✅ Rehydrated discover playlist modal: ${playlistName}`);
|
|
|
} else {
|
|
|
console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`);
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error(`❌ Error rehydrating discover playlist modal:`, error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function rehydrateEnhancedSearchModal(virtualPlaylistId, playlistName, batchId) {
|
|
|
/**
|
|
|
* Rehydrates an enhanced search download modal from backend process data.
|
|
|
* Fetches item data from searchDownloadBubbles and recreates the modal.
|
|
|
*/
|
|
|
try {
|
|
|
console.log(`💧 Rehydrating enhanced search modal: ${virtualPlaylistId} (${playlistName})`);
|
|
|
|
|
|
// Find the download in searchDownloadBubbles
|
|
|
let downloadData = null;
|
|
|
for (const artistName in searchDownloadBubbles) {
|
|
|
const bubble = searchDownloadBubbles[artistName];
|
|
|
const download = bubble.downloads.find(d => d.virtualPlaylistId === virtualPlaylistId);
|
|
|
if (download) {
|
|
|
downloadData = download;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (!downloadData) {
|
|
|
console.warn(`⚠️ No download data found in searchDownloadBubbles for ${virtualPlaylistId}`);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const { item, type } = downloadData;
|
|
|
|
|
|
if (type === 'album') {
|
|
|
// For albums, fetch tracks (pass name/artist for Hydrabase support)
|
|
|
console.log(`💧 Album download - fetching album ${item.id}...`);
|
|
|
|
|
|
try {
|
|
|
const _sap1 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' });
|
|
|
const response = await fetch(`/api/spotify/album/${item.id}?${_sap1}`);
|
|
|
if (!response.ok) {
|
|
|
console.error(`❌ Failed to fetch album: ${response.status}`);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const albumData = await response.json();
|
|
|
if (!albumData.tracks || albumData.tracks.length === 0) {
|
|
|
console.error(`❌ No tracks in album`);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const spotifyTracks = albumData.tracks.map(track => ({
|
|
|
id: track.id,
|
|
|
name: track.name,
|
|
|
artists: track.artists || [{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }],
|
|
|
album: {
|
|
|
name: item.name,
|
|
|
images: item.image_url ? [{ url: item.image_url }] : []
|
|
|
},
|
|
|
duration_ms: track.duration_ms || 0
|
|
|
}));
|
|
|
|
|
|
console.log(`✅ Retrieved ${spotifyTracks.length} tracks for album`);
|
|
|
|
|
|
// Create modal
|
|
|
await openDownloadMissingModalForArtistAlbum(
|
|
|
virtualPlaylistId,
|
|
|
item.name,
|
|
|
spotifyTracks,
|
|
|
item,
|
|
|
{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' },
|
|
|
false // Don't show loading overlay
|
|
|
);
|
|
|
|
|
|
// Update process
|
|
|
const process = activeDownloadProcesses[virtualPlaylistId];
|
|
|
if (process) {
|
|
|
process.status = 'running';
|
|
|
process.batchId = batchId;
|
|
|
subscribeToDownloadBatch(batchId);
|
|
|
|
|
|
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
|
|
|
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
|
|
|
if (beginBtn) beginBtn.style.display = 'none';
|
|
|
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
|
|
|
|
|
// Hide modal for background rehydration
|
|
|
if (process.modalElement) {
|
|
|
process.modalElement.style.display = 'none';
|
|
|
console.log(`🔍 Hiding rehydrated modal for background processing: ${playlistName}`);
|
|
|
}
|
|
|
|
|
|
// Start polling for live updates
|
|
|
startModalDownloadPolling(virtualPlaylistId);
|
|
|
|
|
|
console.log(`✅ Rehydrated enhanced search album modal: ${playlistName}`);
|
|
|
} else {
|
|
|
console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`);
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error(`❌ Error fetching album:`, error);
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
// For tracks, create enriched track and open modal
|
|
|
console.log(`💧 Track download - creating modal for ${item.name}...`);
|
|
|
|
|
|
const enrichedTrack = {
|
|
|
id: item.id,
|
|
|
name: item.name,
|
|
|
artists: item.artists || [{ name: item.artist || 'Unknown Artist' }],
|
|
|
album: item.album || {
|
|
|
name: item.album?.name || 'Unknown Album',
|
|
|
images: item.image_url ? [{ url: item.image_url }] : []
|
|
|
},
|
|
|
duration_ms: item.duration_ms || 0
|
|
|
};
|
|
|
|
|
|
// Create modal
|
|
|
await openDownloadMissingModalForYouTube(
|
|
|
virtualPlaylistId,
|
|
|
`${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`,
|
|
|
[enrichedTrack]
|
|
|
);
|
|
|
|
|
|
// Update process
|
|
|
const process = activeDownloadProcesses[virtualPlaylistId];
|
|
|
if (process) {
|
|
|
process.status = 'running';
|
|
|
process.batchId = batchId;
|
|
|
subscribeToDownloadBatch(batchId);
|
|
|
|
|
|
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
|
|
|
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
|
|
|
if (beginBtn) beginBtn.style.display = 'none';
|
|
|
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
|
|
|
|
|
// Hide modal for background rehydration
|
|
|
if (process.modalElement) {
|
|
|
process.modalElement.style.display = 'none';
|
|
|
console.log(`🔍 Hiding rehydrated modal for background processing: ${playlistName}`);
|
|
|
}
|
|
|
|
|
|
// Start polling for live updates
|
|
|
startModalDownloadPolling(virtualPlaylistId);
|
|
|
|
|
|
console.log(`✅ Rehydrated enhanced search track modal: ${playlistName}`);
|
|
|
} else {
|
|
|
console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error(`❌ Error rehydrating enhanced search modal:`, error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function rehydrateModal(processInfo, userRequested = false) {
|
|
|
const { playlist_id, playlist_name, batch_id, current_cycle } = processInfo;
|
|
|
console.log(`💧 Rehydrating modal for "${playlist_name}" (batch: ${batch_id}) - User requested: ${userRequested}`);
|
|
|
|
|
|
// Handle YouTube virtual playlists - skip rehydration here, handled by YouTube system
|
|
|
if (playlist_id.startsWith('youtube_')) {
|
|
|
console.log(`⏭️ Skipping YouTube virtual playlist rehydration - handled by YouTube system`);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Handle Beatport virtual playlists - skip rehydration here, handled by Beatport system
|
|
|
if (playlist_id.startsWith('beatport_')) {
|
|
|
console.log(`⏭️ Skipping Beatport virtual playlist rehydration - handled by Beatport system`);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Handle artist album virtual playlists
|
|
|
if (playlist_id.startsWith('artist_album_')) {
|
|
|
console.log(`💧 Rehydrating artist album virtual playlist: ${playlist_id}`);
|
|
|
await rehydrateArtistAlbumModal(playlist_id, playlist_name, batch_id);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Handle discover virtual playlists (Fresh Tape, The Archives)
|
|
|
if (playlist_id.startsWith('discover_')) {
|
|
|
console.log(`💧 Rehydrating discover playlist: ${playlist_id}`);
|
|
|
await rehydrateDiscoverPlaylistModal(playlist_id, playlist_name, batch_id);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Handle enhanced search virtual playlists (albums and tracks)
|
|
|
if (playlist_id.startsWith('enhanced_search_album_') || playlist_id.startsWith('enhanced_search_track_')) {
|
|
|
console.log(`💧 Rehydrating enhanced search virtual playlist: ${playlist_id}`);
|
|
|
await rehydrateEnhancedSearchModal(playlist_id, playlist_name, batch_id);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Handle wishlist processes specially
|
|
|
if (playlist_id === "wishlist") {
|
|
|
console.log(`💧 [Rehydrate] Handling wishlist modal for active process: ${batch_id}`);
|
|
|
|
|
|
// Check if modal already exists and is visible
|
|
|
const existingProcess = activeDownloadProcesses[playlist_id];
|
|
|
const modalAlreadyOpen = existingProcess && existingProcess.modalElement &&
|
|
|
existingProcess.modalElement.style.display === 'flex';
|
|
|
|
|
|
if (modalAlreadyOpen) {
|
|
|
console.log(`💧 [Rehydrate] Wishlist modal already open - updating existing modal with auto-process state`);
|
|
|
|
|
|
// Update existing process with new batch info
|
|
|
existingProcess.status = 'running';
|
|
|
existingProcess.batchId = batch_id;
|
|
|
|
|
|
// Update UI to reflect running state
|
|
|
const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`);
|
|
|
const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`);
|
|
|
if (beginBtn) beginBtn.style.display = 'none';
|
|
|
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
|
|
|
|
|
// Ensure polling is active for live updates
|
|
|
if (!existingProcess.intervalId) {
|
|
|
console.log(`💧 [Rehydrate] Starting polling for existing modal`);
|
|
|
startModalDownloadPolling(playlist_id);
|
|
|
}
|
|
|
|
|
|
console.log(`✅ [Rehydrate] Successfully updated existing wishlist modal for auto-process`);
|
|
|
} else {
|
|
|
// Only create modal if user requested it - don't create for background auto-processing
|
|
|
if (userRequested) {
|
|
|
console.log(`💧 [Rehydrate] User requested - creating wishlist modal for active process: ${batch_id}`);
|
|
|
|
|
|
// Create the modal with current server state (pass category filter for auto-processing)
|
|
|
await openDownloadMissingWishlistModal(current_cycle);
|
|
|
const process = activeDownloadProcesses[playlist_id];
|
|
|
if (!process) {
|
|
|
console.error('❌ [Rehydrate] Failed to create wishlist process in activeDownloadProcesses');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Sync process state with server
|
|
|
console.log(`✅ [Rehydrate] Syncing wishlist process state - batchId: ${batch_id}, status: running`);
|
|
|
process.status = 'running';
|
|
|
process.batchId = batch_id;
|
|
|
|
|
|
// Update UI to reflect running state
|
|
|
const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`);
|
|
|
const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`);
|
|
|
if (beginBtn) beginBtn.style.display = 'none';
|
|
|
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
|
|
|
|
|
// Start polling for live updates
|
|
|
startModalDownloadPolling(playlist_id);
|
|
|
|
|
|
// Show modal
|
|
|
console.log('👤 [Rehydrate] User requested - showing wishlist modal');
|
|
|
process.modalElement.style.display = 'flex';
|
|
|
WishlistModalState.setVisible();
|
|
|
WishlistModalState.clearUserClosed();
|
|
|
} else {
|
|
|
console.log('🔄 [Rehydrate] Background auto-processing detected - NOT creating modal (user must click wishlist button to see progress)');
|
|
|
// Don't create modal for background auto-processing
|
|
|
// User must click the wishlist button to see the modal
|
|
|
}
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Handle Deezer ARL playlist processes — ensure playlist data is in spotifyPlaylists for modal reuse
|
|
|
if (playlist_id.startsWith('deezer_arl_') && !spotifyPlaylists.find(p => p.id === playlist_id)) {
|
|
|
const rawId = playlist_id.replace('deezer_arl_', '');
|
|
|
const deezerPlaylist = deezerArlPlaylists.find(p => String(p.id) === rawId);
|
|
|
if (deezerPlaylist) {
|
|
|
spotifyPlaylists.push({
|
|
|
id: playlist_id,
|
|
|
name: deezerPlaylist.name,
|
|
|
track_count: deezerPlaylist.track_count || 0,
|
|
|
image_url: deezerPlaylist.image_url || '',
|
|
|
owner: deezerPlaylist.owner || '',
|
|
|
});
|
|
|
} else {
|
|
|
// Playlists not loaded yet — use process info as fallback
|
|
|
spotifyPlaylists.push({
|
|
|
id: playlist_id,
|
|
|
name: playlist_name || 'Deezer Playlist',
|
|
|
track_count: 0,
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Handle regular Spotify / Deezer ARL playlist processes
|
|
|
let playlistData = spotifyPlaylists.find(p => p.id === playlist_id);
|
|
|
if (!playlistData) {
|
|
|
console.warn(`Cannot rehydrate modal: Playlist data for ${playlist_id} not loaded.`);
|
|
|
return;
|
|
|
}
|
|
|
await openDownloadMissingModal(playlist_id);
|
|
|
const process = activeDownloadProcesses[playlist_id];
|
|
|
if (!process) return;
|
|
|
|
|
|
process.status = 'running';
|
|
|
process.batchId = batch_id;
|
|
|
updatePlaylistCardUI(playlist_id);
|
|
|
updateRefreshButtonState();
|
|
|
|
|
|
document.getElementById(`begin-analysis-btn-${playlist_id}`).style.display = 'none';
|
|
|
document.getElementById(`cancel-all-btn-${playlist_id}`).style.display = 'inline-block';
|
|
|
|
|
|
// Hide wishlist button if it exists
|
|
|
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlist_id}`);
|
|
|
if (wishlistBtn) wishlistBtn.style.display = 'none';
|
|
|
|
|
|
startModalDownloadPolling(playlist_id);
|
|
|
|
|
|
process.modalElement.style.display = 'none';
|
|
|
}
|
|
|
|
|
|
// ===================================================================
|
|
|
// YOUTUBE PLAYLIST BACKEND HYDRATION FUNCTIONS
|
|
|
// ===================================================================
|
|
|
|
|
|
async function loadYouTubePlaylistsFromBackend() {
|
|
|
// Load all stored YouTube playlists from backend and recreate cards (similar to Spotify hydration)
|
|
|
try {
|
|
|
console.log('📋 Loading YouTube playlists from backend...');
|
|
|
|
|
|
const response = await fetch('/api/youtube/playlists');
|
|
|
if (!response.ok) {
|
|
|
const error = await response.json();
|
|
|
throw new Error(error.error || 'Failed to fetch YouTube playlists');
|
|
|
}
|
|
|
|
|
|
const data = await response.json();
|
|
|
const playlists = data.playlists || [];
|
|
|
|
|
|
console.log(`🎬 Found ${playlists.length} stored YouTube playlists in backend`);
|
|
|
|
|
|
if (playlists.length === 0) {
|
|
|
console.log('📋 No YouTube playlists to hydrate');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const container = document.getElementById('youtube-playlist-container');
|
|
|
|
|
|
// Create cards for playlists that don't already exist (avoid duplicates)
|
|
|
for (const playlistInfo of playlists) {
|
|
|
const urlHash = playlistInfo.url_hash;
|
|
|
|
|
|
// Check if card already exists (from rehydration or previous loading)
|
|
|
if (youtubePlaylistStates[urlHash] && youtubePlaylistStates[urlHash].cardElement &&
|
|
|
document.body.contains(youtubePlaylistStates[urlHash].cardElement)) {
|
|
|
console.log(`⏭️ Skipping existing YouTube playlist card: ${playlistInfo.playlist.name}`);
|
|
|
|
|
|
// Update existing state with backend data
|
|
|
const state = youtubePlaylistStates[urlHash];
|
|
|
state.phase = playlistInfo.phase;
|
|
|
state.discoveryProgress = playlistInfo.discovery_progress;
|
|
|
state.spotifyMatches = playlistInfo.spotify_matches;
|
|
|
state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id;
|
|
|
|
|
|
// Fetch discovery results for existing cards too if they don't have them
|
|
|
if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering' &&
|
|
|
(!state.discoveryResults || state.discoveryResults.length === 0)) {
|
|
|
try {
|
|
|
console.log(`🔍 Fetching missing discovery results for existing card: ${playlistInfo.playlist.name}`);
|
|
|
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
|
|
|
if (stateResponse.ok) {
|
|
|
const fullState = await stateResponse.json();
|
|
|
if (fullState.discovery_results) {
|
|
|
state.discoveryResults = fullState.discovery_results;
|
|
|
state.syncPlaylistId = fullState.sync_playlist_id;
|
|
|
state.syncProgress = fullState.sync_progress || {};
|
|
|
console.log(`✅ Restored ${state.discoveryResults.length} discovery results for existing card`);
|
|
|
}
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.warn(`⚠️ Error fetching discovery results for existing card:`, error.message);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
console.log(`🎬 Creating YouTube playlist card: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase})`);
|
|
|
createYouTubeCardFromBackendState(playlistInfo);
|
|
|
|
|
|
// Fetch discovery results for non-fresh playlists (same logic as rehydrateYouTubePlaylist)
|
|
|
if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering') {
|
|
|
try {
|
|
|
console.log(`🔍 Fetching discovery results for: ${playlistInfo.playlist.name}`);
|
|
|
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
|
|
|
if (stateResponse.ok) {
|
|
|
const fullState = await stateResponse.json();
|
|
|
console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`);
|
|
|
|
|
|
// Store discovery results in local state
|
|
|
const state = youtubePlaylistStates[urlHash];
|
|
|
if (fullState.discovery_results && state) {
|
|
|
state.discoveryResults = fullState.discovery_results;
|
|
|
state.syncPlaylistId = fullState.sync_playlist_id;
|
|
|
state.syncProgress = fullState.sync_progress || {};
|
|
|
console.log(`✅ Restored ${state.discoveryResults.length} discovery results for: ${playlistInfo.playlist.name}`);
|
|
|
}
|
|
|
} else {
|
|
|
console.warn(`⚠️ Could not fetch discovery results for: ${playlistInfo.playlist.name}`);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.warn(`⚠️ Error fetching discovery results for ${playlistInfo.playlist.name}:`, error.message);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Rehydrate download modals for YouTube playlists in downloading/download_complete phases
|
|
|
for (const playlistInfo of playlists) {
|
|
|
if ((playlistInfo.phase === 'downloading' || playlistInfo.phase === 'download_complete') &&
|
|
|
playlistInfo.converted_spotify_playlist_id && playlistInfo.download_process_id) {
|
|
|
|
|
|
const convertedPlaylistId = playlistInfo.converted_spotify_playlist_id;
|
|
|
|
|
|
if (!activeDownloadProcesses[convertedPlaylistId]) {
|
|
|
console.log(`💧 Rehydrating download modal for YouTube playlist: ${playlistInfo.playlist.name}`);
|
|
|
try {
|
|
|
// Create the download modal using the YouTube-specific function
|
|
|
const spotifyTracks = youtubePlaylistStates[playlistInfo.url_hash]?.discoveryResults
|
|
|
?.filter(result => result.spotify_data)
|
|
|
?.map(result => result.spotify_data) || [];
|
|
|
|
|
|
if (spotifyTracks.length > 0) {
|
|
|
await openDownloadMissingModalForYouTube(
|
|
|
convertedPlaylistId,
|
|
|
playlistInfo.playlist.name,
|
|
|
spotifyTracks
|
|
|
);
|
|
|
|
|
|
// Set the modal to running state with the correct batch ID
|
|
|
const process = activeDownloadProcesses[convertedPlaylistId];
|
|
|
if (process) {
|
|
|
process.status = 'running';
|
|
|
process.batchId = playlistInfo.download_process_id;
|
|
|
|
|
|
// Update UI to running state
|
|
|
const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`);
|
|
|
const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`);
|
|
|
if (beginBtn) beginBtn.style.display = 'none';
|
|
|
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
|
|
|
|
|
// Start polling for this process
|
|
|
startModalDownloadPolling(convertedPlaylistId);
|
|
|
|
|
|
// Hide modal since this is background rehydration
|
|
|
process.modalElement.style.display = 'none';
|
|
|
console.log(`✅ Rehydrated download modal for YouTube playlist: ${playlistInfo.playlist.name}`);
|
|
|
}
|
|
|
} else {
|
|
|
console.warn(`⚠️ No Spotify tracks found for YouTube download modal: ${playlistInfo.playlist.name}`);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error(`❌ Error rehydrating download modal for ${playlistInfo.playlist.name}:`, error);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
console.log(`✅ Successfully hydrated ${playlists.length} YouTube playlists from backend`);
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error('❌ Error loading YouTube playlists from backend:', error);
|
|
|
showToast(`Error loading YouTube playlists: ${error.message}`, 'error');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function loadBeatportChartsFromBackend() {
|
|
|
// Load all stored Beatport charts from backend and recreate cards (similar to YouTube hydration)
|
|
|
try {
|
|
|
console.log('📋 Loading Beatport charts from backend...');
|
|
|
|
|
|
const signal = getBeatportContentSignal();
|
|
|
const response = await fetch('/api/beatport/charts', signal ? { signal } : undefined);
|
|
|
if (!response.ok) {
|
|
|
const error = await response.json();
|
|
|
throw new Error(error.error || 'Failed to fetch Beatport charts');
|
|
|
}
|
|
|
|
|
|
const charts = await response.json();
|
|
|
|
|
|
console.log(`🎧 Found ${charts.length} stored Beatport charts in backend`);
|
|
|
|
|
|
if (charts.length === 0) {
|
|
|
console.log('📋 No Beatport charts to hydrate');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const container = document.getElementById('beatport-playlist-container');
|
|
|
|
|
|
// Create cards for charts that don't already exist (avoid duplicates)
|
|
|
for (const chartInfo of charts) {
|
|
|
const chartHash = chartInfo.hash;
|
|
|
|
|
|
// Check if card already exists (from previous loading)
|
|
|
if (beatportChartStates[chartHash] && beatportChartStates[chartHash].cardElement &&
|
|
|
document.body.contains(beatportChartStates[chartHash].cardElement)) {
|
|
|
console.log(`⏭️ Skipping existing Beatport chart card: ${chartInfo.name}`);
|
|
|
|
|
|
// Update existing state with backend data
|
|
|
const state = beatportChartStates[chartHash];
|
|
|
state.phase = chartInfo.phase;
|
|
|
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
console.log(`🎧 Creating Beatport chart card: ${chartInfo.name} (Phase: ${chartInfo.phase})`);
|
|
|
createBeatportCardFromBackendState(chartInfo);
|
|
|
|
|
|
// Fetch full state for non-fresh charts to restore discovery results
|
|
|
if (chartInfo.phase !== 'fresh') {
|
|
|
try {
|
|
|
console.log(`🔍 Fetching full state for: ${chartInfo.name}`);
|
|
|
const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`, signal ? { signal } : undefined);
|
|
|
if (stateResponse.ok) {
|
|
|
const fullState = await stateResponse.json();
|
|
|
console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`);
|
|
|
|
|
|
// Store in YouTube state system (since Beatport reuses it)
|
|
|
if (fullState.discovery_results && fullState.discovery_results.length > 0) {
|
|
|
// Transform backend results to frontend format (like Tidal does)
|
|
|
const transformedResults = fullState.discovery_results.map((result, index) => ({
|
|
|
index: result.index !== undefined ? result.index : index,
|
|
|
yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown',
|
|
|
yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown',
|
|
|
status: result.status === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'),
|
|
|
status_class: result.status_class || (result.status === 'found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')),
|
|
|
spotify_track: result.spotify_data ? result.spotify_data.name : '-',
|
|
|
spotify_artist: result.spotify_data && result.spotify_data.artists ?
|
|
|
result.spotify_data.artists.map(a => a.name || a).join(', ') : '-',
|
|
|
spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : '-'
|
|
|
}));
|
|
|
|
|
|
// Create Beatport state in YouTube system for modal functionality
|
|
|
youtubePlaylistStates[chartHash] = {
|
|
|
phase: fullState.phase,
|
|
|
playlist: {
|
|
|
name: chartInfo.name,
|
|
|
tracks: chartInfo.chart_data.tracks,
|
|
|
description: `${chartInfo.track_count} tracks from ${chartInfo.name}`,
|
|
|
source: 'beatport'
|
|
|
},
|
|
|
is_beatport_playlist: true,
|
|
|
beatport_chart_type: chartInfo.chart_data.chart_type,
|
|
|
beatport_chart_hash: chartHash,
|
|
|
discovery_progress: fullState.discovery_progress,
|
|
|
discoveryProgress: fullState.discovery_progress,
|
|
|
spotify_matches: fullState.spotify_matches,
|
|
|
spotifyMatches: fullState.spotify_matches,
|
|
|
discovery_results: fullState.discovery_results,
|
|
|
discoveryResults: transformedResults,
|
|
|
convertedSpotifyPlaylistId: fullState.converted_spotify_playlist_id,
|
|
|
download_process_id: fullState.download_process_id,
|
|
|
syncPlaylistId: fullState.sync_playlist_id,
|
|
|
syncProgress: fullState.sync_progress || {}
|
|
|
};
|
|
|
|
|
|
console.log(`✅ Restored ${transformedResults.length} discovery results for: ${chartInfo.name}`);
|
|
|
}
|
|
|
} else {
|
|
|
console.warn(`⚠️ Could not fetch full state for: ${chartInfo.name}`);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
if (error && error.name === 'AbortError') throw error;
|
|
|
console.warn(`⚠️ Error fetching full state for ${chartInfo.name}:`, error.message);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Rehydrate download modals for Beatport charts in downloading/download_complete phases
|
|
|
for (const chartInfo of charts) {
|
|
|
if ((chartInfo.phase === 'downloading' || chartInfo.phase === 'download_complete') &&
|
|
|
chartInfo.converted_spotify_playlist_id && chartInfo.download_process_id) {
|
|
|
|
|
|
const convertedPlaylistId = chartInfo.converted_spotify_playlist_id;
|
|
|
console.log(`📥 Rehydrating download modal for Beatport chart: ${chartInfo.name} (Playlist: ${convertedPlaylistId})`);
|
|
|
|
|
|
// Set up active download process for Beatport chart (like YouTube/Tidal)
|
|
|
try {
|
|
|
// Rehydrate the chart state first to get discovery results
|
|
|
await rehydrateBeatportChart(chartInfo, false);
|
|
|
|
|
|
// Create the download modal using the Beatport-specific function (like YouTube)
|
|
|
if (!activeDownloadProcesses[convertedPlaylistId]) {
|
|
|
// Get tracks from the rehydrated state
|
|
|
const ytState = youtubePlaylistStates[chartInfo.hash];
|
|
|
let spotifyTracks = [];
|
|
|
|
|
|
if (ytState && ytState.discovery_results) {
|
|
|
spotifyTracks = ytState.discovery_results
|
|
|
.filter(result => result.spotify_data)
|
|
|
.map(result => {
|
|
|
const track = result.spotify_data;
|
|
|
// Ensure artists is an array of strings
|
|
|
if (track.artists && Array.isArray(track.artists)) {
|
|
|
track.artists = track.artists.map(artist =>
|
|
|
typeof artist === 'string' ? artist : (artist.name || artist)
|
|
|
);
|
|
|
} else if (track.artists && typeof track.artists === 'string') {
|
|
|
track.artists = [track.artists];
|
|
|
} else {
|
|
|
track.artists = ['Unknown Artist'];
|
|
|
}
|
|
|
return {
|
|
|
id: track.id,
|
|
|
name: track.name,
|
|
|
artists: track.artists,
|
|
|
album: track.album || 'Unknown Album',
|
|
|
duration_ms: track.duration_ms || 0,
|
|
|
external_urls: track.external_urls || {}
|
|
|
};
|
|
|
});
|
|
|
}
|
|
|
|
|
|
if (spotifyTracks.length > 0) {
|
|
|
await openDownloadMissingModalForYouTube(
|
|
|
convertedPlaylistId,
|
|
|
chartInfo.name,
|
|
|
spotifyTracks
|
|
|
);
|
|
|
|
|
|
// Set the modal to running state with the correct batch ID
|
|
|
const process = activeDownloadProcesses[convertedPlaylistId];
|
|
|
if (process) {
|
|
|
process.status = chartInfo.phase === 'download_complete' ? 'complete' : 'running';
|
|
|
process.batchId = chartInfo.download_process_id;
|
|
|
|
|
|
// Update UI to running state
|
|
|
const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`);
|
|
|
const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`);
|
|
|
if (beginBtn) beginBtn.style.display = 'none';
|
|
|
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
|
|
|
|
|
// Start polling for this process
|
|
|
startModalDownloadPolling(convertedPlaylistId);
|
|
|
|
|
|
// Hide modal since this is background rehydration
|
|
|
process.modalElement.style.display = 'none';
|
|
|
console.log(`✅ Rehydrated download modal for Beatport chart: ${chartInfo.name}`);
|
|
|
}
|
|
|
} else {
|
|
|
console.warn(`⚠️ No Spotify tracks found for Beatport download modal: ${chartInfo.name}`);
|
|
|
}
|
|
|
}
|
|
|
} catch (error) {
|
|
|
if (error && error.name === 'AbortError') throw error;
|
|
|
console.warn(`⚠️ Error setting up download process for Beatport chart "${chartInfo.name}":`, error.message);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
throwIfBeatportLoadAborted();
|
|
|
console.log(`✅ Successfully loaded and rehydrated ${charts.length} Beatport charts`);
|
|
|
|
|
|
// Start polling for any charts that are still in discovering phase
|
|
|
for (const chartInfo of charts) {
|
|
|
if (chartInfo.phase === 'discovering') {
|
|
|
console.log(`🔄 [Backend Loading] Auto-starting polling for discovering chart: ${chartInfo.name}`);
|
|
|
throwIfBeatportLoadAborted();
|
|
|
startBeatportDiscoveryPolling(chartInfo.hash);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Update clear button state after loading charts
|
|
|
updateBeatportClearButtonState();
|
|
|
|
|
|
} catch (error) {
|
|
|
if (error && error.name === 'AbortError') {
|
|
|
console.log('⏹ Beatport chart hydration aborted');
|
|
|
return;
|
|
|
}
|
|
|
console.error('❌ Error loading Beatport charts from backend:', error);
|
|
|
showToast(`Error loading Beatport charts: ${error.message}`, 'error');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function loadListenBrainzPlaylistsFromBackend() {
|
|
|
// Load all stored ListenBrainz playlist states from backend for persistence (similar to Beatport hydration)
|
|
|
try {
|
|
|
console.log('📋 Loading ListenBrainz playlists from backend...');
|
|
|
|
|
|
const response = await fetch('/api/listenbrainz/playlists');
|
|
|
if (!response.ok) {
|
|
|
const error = await response.json();
|
|
|
throw new Error(error.error || 'Failed to fetch ListenBrainz playlists');
|
|
|
}
|
|
|
|
|
|
const data = await response.json();
|
|
|
const playlists = data.playlists || [];
|
|
|
|
|
|
console.log(`🎵 Found ${playlists.length} stored ListenBrainz playlists in backend`);
|
|
|
|
|
|
if (playlists.length === 0) {
|
|
|
console.log('📋 No ListenBrainz playlists to hydrate');
|
|
|
listenbrainzPlaylistsLoaded = true;
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Restore state for each playlist
|
|
|
for (const playlistInfo of playlists) {
|
|
|
const playlistMbid = playlistInfo.playlist_mbid;
|
|
|
|
|
|
console.log(`🎵 Hydrating ListenBrainz playlist: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase}, MBID: ${playlistMbid})`);
|
|
|
|
|
|
// Fetch full state for non-fresh playlists to restore discovery results
|
|
|
if (playlistInfo.phase !== 'fresh') {
|
|
|
try {
|
|
|
console.log(`🔍 Fetching full state for: ${playlistInfo.playlist.name}`);
|
|
|
const stateResponse = await fetch(`/api/listenbrainz/state/${playlistMbid}`);
|
|
|
if (stateResponse.ok) {
|
|
|
const fullState = await stateResponse.json();
|
|
|
console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`);
|
|
|
|
|
|
// Transform backend results to frontend format (like Beatport does)
|
|
|
const transformedResults = (fullState.discovery_results || []).map((result, index) => ({
|
|
|
index: result.index !== undefined ? result.index : index,
|
|
|
yt_track: result.lb_track || result.track_name || 'Unknown',
|
|
|
yt_artist: result.lb_artist || result.artist_name || 'Unknown',
|
|
|
status: result.status === 'found' || result.status === '✅ Found' || result.status_class === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'),
|
|
|
status_class: result.status_class || (result.status === 'found' || result.status === '✅ Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')),
|
|
|
spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
|
|
|
spotify_artist: result.spotify_data && result.spotify_data.artists ?
|
|
|
(Array.isArray(result.spotify_data.artists) ? (typeof result.spotify_data.artists[0] === 'object' ? result.spotify_data.artists[0].name : result.spotify_data.artists[0]) : result.spotify_data.artists) : (result.spotify_artist || '-'),
|
|
|
spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'),
|
|
|
spotify_data: result.spotify_data,
|
|
|
duration: result.duration || '0:00'
|
|
|
}));
|
|
|
|
|
|
// Create ListenBrainz state with both naming conventions
|
|
|
listenbrainzPlaylistStates[playlistMbid] = {
|
|
|
phase: fullState.phase,
|
|
|
playlist: fullState.playlist,
|
|
|
is_listenbrainz_playlist: true,
|
|
|
playlist_mbid: playlistMbid,
|
|
|
// Store with both naming conventions
|
|
|
discovery_results: fullState.discovery_results || [],
|
|
|
discoveryResults: transformedResults,
|
|
|
discovery_progress: fullState.discovery_progress || 0,
|
|
|
discoveryProgress: fullState.discovery_progress || 0,
|
|
|
spotify_matches: fullState.spotify_matches || 0,
|
|
|
spotifyMatches: fullState.spotify_matches || 0,
|
|
|
spotify_total: fullState.spotify_total || 0,
|
|
|
spotifyTotal: fullState.spotify_total || 0,
|
|
|
convertedSpotifyPlaylistId: fullState.converted_spotify_playlist_id,
|
|
|
download_process_id: fullState.download_process_id
|
|
|
};
|
|
|
|
|
|
console.log(`✅ Restored ${transformedResults.length} discovery results for: ${playlistInfo.playlist.name}`);
|
|
|
} else {
|
|
|
console.warn(`⚠️ Could not fetch full state for: ${playlistInfo.playlist.name}`);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.warn(`⚠️ Error fetching full state for ${playlistInfo.playlist.name}:`, error.message);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Start polling for any playlists that are still in discovering phase
|
|
|
for (const playlistInfo of playlists) {
|
|
|
if (playlistInfo.phase === 'discovering') {
|
|
|
console.log(`🔄 [Backend Loading] Auto-starting polling for discovering playlist: ${playlistInfo.playlist.name}`);
|
|
|
startListenBrainzDiscoveryPolling(playlistInfo.playlist_mbid);
|
|
|
}
|
|
|
// Show sync button for discovered playlists (hidden by default)
|
|
|
else if (playlistInfo.phase === 'discovered' || playlistInfo.phase === 'syncing' || playlistInfo.phase === 'sync_complete') {
|
|
|
const playlistId = `discover-lb-playlist-${playlistInfo.playlist_mbid}`;
|
|
|
const syncBtn = document.getElementById(`${playlistId}-sync-btn`);
|
|
|
if (syncBtn) {
|
|
|
syncBtn.style.display = 'inline-block';
|
|
|
console.log(`✅ Showing sync button for discovered playlist: ${playlistInfo.playlist.name}`);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
listenbrainzPlaylistsLoaded = true;
|
|
|
console.log(`✅ Successfully loaded and rehydrated ${playlists.length} ListenBrainz playlists`);
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error('❌ Error loading ListenBrainz playlists from backend:', error);
|
|
|
listenbrainzPlaylistsLoaded = true; // Mark as loaded even on error to prevent retries
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function createBeatportCardFromBackendState(chartInfo) {
|
|
|
// Create Beatport chart card from backend state data
|
|
|
const chartHash = chartInfo.hash;
|
|
|
const chartData = chartInfo.chart_data;
|
|
|
const phase = chartInfo.phase;
|
|
|
|
|
|
const container = document.getElementById('beatport-playlist-container');
|
|
|
|
|
|
// Remove placeholder if it exists
|
|
|
const placeholder = container.querySelector('.playlist-placeholder');
|
|
|
if (placeholder) {
|
|
|
placeholder.remove();
|
|
|
}
|
|
|
|
|
|
// Create card HTML using same structure as createBeatportCard
|
|
|
const cardHtml = `
|
|
|
<div class="youtube-playlist-card" id="beatport-card-${chartHash}">
|
|
|
<div class="playlist-card-icon">🎧</div>
|
|
|
<div class="playlist-card-content">
|
|
|
<div class="playlist-card-name">${escapeHtml(chartInfo.name)}</div>
|
|
|
<div class="playlist-card-info">
|
|
|
<span class="playlist-card-track-count">${chartInfo.track_count} tracks</span>
|
|
|
<span class="playlist-card-phase-text" style="color: ${getPhaseColor(phase)};">${getPhaseText(phase)}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}">
|
|
|
♪ ${chartInfo.spotify_total} / ✓ ${chartInfo.spotify_matches} / ✗ ${chartInfo.spotify_total - chartInfo.spotify_matches} (${Math.round((chartInfo.spotify_matches / chartInfo.spotify_total) * 100) || 0}%)
|
|
|
</div>
|
|
|
<button class="playlist-card-action-btn">${getActionButtonText(phase)}</button>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
container.insertAdjacentHTML('beforeend', cardHtml);
|
|
|
|
|
|
// Initialize state
|
|
|
beatportChartStates[chartHash] = {
|
|
|
phase: phase,
|
|
|
chart: chartData,
|
|
|
cardElement: document.getElementById(`beatport-card-${chartHash}`)
|
|
|
};
|
|
|
|
|
|
// Add click handler
|
|
|
const card = document.getElementById(`beatport-card-${chartHash}`);
|
|
|
if (card) {
|
|
|
card.addEventListener('click', async () => await handleBeatportCardClick(chartHash));
|
|
|
}
|
|
|
|
|
|
console.log(`🃏 Created Beatport card from backend state: ${chartInfo.name} (${phase})`);
|
|
|
}
|
|
|
|
|
|
async function rehydrateBeatportChart(chartInfo, userRequested = false) {
|
|
|
// Rehydrate Beatport chart state and optionally open modal (similar to rehydrateYouTubePlaylist)
|
|
|
const chartHash = chartInfo.hash;
|
|
|
const chartName = chartInfo.name;
|
|
|
|
|
|
try {
|
|
|
console.log(`🔄 [Rehydration] Starting rehydration for Beatport chart: ${chartName}`);
|
|
|
|
|
|
// Get full state from backend including discovery results
|
|
|
let fullState;
|
|
|
try {
|
|
|
const signal = getBeatportContentSignal();
|
|
|
const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`, signal ? { signal } : undefined);
|
|
|
if (stateResponse.ok) {
|
|
|
fullState = await stateResponse.json();
|
|
|
console.log(`📋 [Rehydration] Retrieved full backend state with ${fullState.discovery_results?.length || 0} discovery results`);
|
|
|
} else {
|
|
|
console.warn(`⚠️ [Rehydration] Could not fetch full state, using basic info`);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
if (error && error.name === 'AbortError') return;
|
|
|
console.warn(`⚠️ [Rehydration] Error fetching full state:`, error.message);
|
|
|
}
|
|
|
|
|
|
const phase = chartInfo.phase;
|
|
|
|
|
|
// Create or update Beatport chart state
|
|
|
if (!beatportChartStates[chartHash]) {
|
|
|
beatportChartStates[chartHash] = {
|
|
|
phase: 'fresh',
|
|
|
chart: chartInfo.chart_data,
|
|
|
cardElement: null
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const state = beatportChartStates[chartHash];
|
|
|
state.phase = phase;
|
|
|
|
|
|
// Transform discovery results if available (like Tidal does)
|
|
|
let transformedResults = [];
|
|
|
if (fullState && fullState.discovery_results) {
|
|
|
transformedResults = fullState.discovery_results.map((result, index) => ({
|
|
|
index: result.index !== undefined ? result.index : index,
|
|
|
yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown',
|
|
|
yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown',
|
|
|
status: result.status === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'),
|
|
|
status_class: result.status_class || (result.status === 'found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')),
|
|
|
spotify_track: result.spotify_data ? result.spotify_data.name : '-',
|
|
|
spotify_artist: result.spotify_data && result.spotify_data.artists ?
|
|
|
result.spotify_data.artists.map(a => a.name || a).join(', ') : '-',
|
|
|
spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : '-'
|
|
|
}));
|
|
|
}
|
|
|
|
|
|
// Store in YouTube state system (since Beatport reuses it)
|
|
|
youtubePlaylistStates[chartHash] = {
|
|
|
phase: phase,
|
|
|
playlist: {
|
|
|
name: chartName,
|
|
|
tracks: chartInfo.chart_data.tracks,
|
|
|
description: `${chartInfo.track_count} tracks from ${chartName}`,
|
|
|
source: 'beatport'
|
|
|
},
|
|
|
is_beatport_playlist: true,
|
|
|
beatport_chart_type: chartInfo.chart_data.chart_type,
|
|
|
beatport_chart_hash: chartHash,
|
|
|
discovery_progress: fullState?.discovery_progress || chartInfo.discovery_progress,
|
|
|
discoveryProgress: fullState?.discovery_progress || chartInfo.discovery_progress,
|
|
|
spotify_matches: fullState?.spotify_matches || chartInfo.spotify_matches,
|
|
|
spotifyMatches: fullState?.spotify_matches || chartInfo.spotify_matches,
|
|
|
discovery_results: fullState?.discovery_results || [],
|
|
|
discoveryResults: transformedResults,
|
|
|
convertedSpotifyPlaylistId: fullState?.converted_spotify_playlist_id || chartInfo.converted_spotify_playlist_id,
|
|
|
download_process_id: fullState?.download_process_id || chartInfo.download_process_id,
|
|
|
syncPlaylistId: fullState?.sync_playlist_id,
|
|
|
syncProgress: fullState?.sync_progress || {}
|
|
|
};
|
|
|
|
|
|
// Restore discovery results if we have them
|
|
|
if (fullState && fullState.discovery_results) {
|
|
|
console.log(`✅ Restored ${fullState.discovery_results.length} discovery results from backend`);
|
|
|
|
|
|
// Update modal if it already exists
|
|
|
const existingModal = document.getElementById(`youtube-discovery-modal-${chartHash}`);
|
|
|
if (existingModal && !existingModal.classList.contains('hidden')) {
|
|
|
console.log(`🔄 Refreshing existing modal with restored discovery results`);
|
|
|
refreshYouTubeDiscoveryModalTable(chartHash);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Update card display
|
|
|
updateBeatportCardPhase(chartHash, phase);
|
|
|
updateBeatportCardProgress(chartHash, {
|
|
|
spotify_total: chartInfo.spotify_total,
|
|
|
spotify_matches: chartInfo.spotify_matches,
|
|
|
failed: chartInfo.spotify_total - chartInfo.spotify_matches
|
|
|
});
|
|
|
|
|
|
// Handle active polling resumption
|
|
|
if (phase === 'discovering') {
|
|
|
console.log(`🔍 Resuming discovery polling for: ${chartName}`);
|
|
|
startBeatportDiscoveryPolling(chartHash);
|
|
|
} else if (phase === 'syncing') {
|
|
|
console.log(`🔄 Resuming sync polling for: ${chartName}`);
|
|
|
startBeatportSyncPolling(chartHash);
|
|
|
}
|
|
|
|
|
|
// Open modal if user requested
|
|
|
if (userRequested) {
|
|
|
switch (phase) {
|
|
|
case 'discovering':
|
|
|
case 'discovered':
|
|
|
case 'syncing':
|
|
|
case 'sync_complete':
|
|
|
openYouTubeDiscoveryModal(chartHash);
|
|
|
break;
|
|
|
case 'downloading':
|
|
|
case 'download_complete':
|
|
|
// Open download modal if we have the converted playlist ID
|
|
|
if (chartInfo.converted_spotify_playlist_id) {
|
|
|
await openDownloadMissingModal(chartInfo.converted_spotify_playlist_id);
|
|
|
}
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
console.log(`✅ Successfully rehydrated Beatport chart: ${chartName}`);
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error(`❌ Error rehydrating Beatport chart "${chartName}":`, error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function createYouTubeCardFromBackendState(playlistInfo) {
|
|
|
// Create YouTube playlist card from backend state data
|
|
|
const urlHash = playlistInfo.url_hash;
|
|
|
const playlist = playlistInfo.playlist;
|
|
|
const phase = playlistInfo.phase;
|
|
|
|
|
|
const container = document.getElementById('youtube-playlist-container');
|
|
|
|
|
|
// Remove placeholder if it exists
|
|
|
const placeholder = container.querySelector('.youtube-playlist-placeholder');
|
|
|
if (placeholder) {
|
|
|
placeholder.remove();
|
|
|
}
|
|
|
|
|
|
// Create card HTML (using EXACT same structure as createYouTubeCard)
|
|
|
const cardHtml = `
|
|
|
<div class="youtube-playlist-card" id="youtube-card-${urlHash}" data-url="${playlistInfo.url}" onclick="handleYouTubeCardClick('${urlHash}')">
|
|
|
<div class="playlist-card-icon youtube-icon">▶</div>
|
|
|
<div class="playlist-card-content">
|
|
|
<div class="playlist-card-name">${escapeHtml(playlist.name)}</div>
|
|
|
<div class="playlist-card-info">
|
|
|
<span class="playlist-card-track-count">${playlist.tracks.length} tracks</span>
|
|
|
<span class="playlist-card-phase-text" style="color: ${getPhaseColor(phase)};">${getPhaseText(phase)}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}">
|
|
|
♪ ${playlistInfo.spotify_total} / ✓ ${playlistInfo.spotify_matches} / ✗ ${playlistInfo.spotify_total - playlistInfo.spotify_matches} / ${Math.round(getProgressWidth(playlistInfo))}%
|
|
|
</div>
|
|
|
<button class="playlist-card-action-btn">${getActionButtonText(phase)}</button>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
container.insertAdjacentHTML('beforeend', cardHtml);
|
|
|
|
|
|
// Store state for UI management (but backend remains source of truth)
|
|
|
youtubePlaylistStates[urlHash] = {
|
|
|
phase: phase,
|
|
|
url: playlistInfo.url,
|
|
|
playlist: playlist,
|
|
|
cardElement: document.getElementById(`youtube-card-${urlHash}`),
|
|
|
discoveryResults: [],
|
|
|
discoveryProgress: playlistInfo.discovery_progress,
|
|
|
spotifyMatches: playlistInfo.spotify_matches,
|
|
|
convertedSpotifyPlaylistId: playlistInfo.converted_spotify_playlist_id,
|
|
|
backendSynced: true // Flag to indicate this came from backend
|
|
|
};
|
|
|
|
|
|
console.log(`🃏 Created YouTube card from backend state: ${playlist.name} (${phase})`);
|
|
|
}
|
|
|
|
|
|
function getActionButtonText(phase) {
|
|
|
switch (phase) {
|
|
|
case 'fresh': return 'Discover';
|
|
|
case 'discovering': return 'View Progress';
|
|
|
case 'discovered': return 'View Results';
|
|
|
case 'syncing': return 'View Sync';
|
|
|
case 'sync_complete': return 'Download';
|
|
|
case 'downloading': return 'View Downloads';
|
|
|
case 'download_complete': return 'Complete';
|
|
|
default: return 'Open';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function getPhaseText(phase) {
|
|
|
switch (phase) {
|
|
|
case 'fresh': return 'Ready to discover';
|
|
|
case 'discovering': return 'Discovering...';
|
|
|
case 'discovered': return 'Discovery Complete';
|
|
|
case 'syncing': return 'Syncing...';
|
|
|
case 'sync_complete': return 'Sync Complete';
|
|
|
case 'downloading': return 'Downloading...';
|
|
|
case 'download_complete': return 'Download Complete';
|
|
|
default: return phase;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function getPhaseColor(phase) {
|
|
|
switch (phase) {
|
|
|
case 'fresh': return '#999';
|
|
|
case 'discovering': case 'syncing': case 'downloading': return '#ffa500';
|
|
|
case 'discovered': case 'sync_complete': case 'download_complete': return 'rgb(var(--accent-rgb))';
|
|
|
default: return '#999';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function getProgressWidth(playlistInfo) {
|
|
|
if (playlistInfo.phase === 'fresh') return 0;
|
|
|
if (playlistInfo.spotify_total === 0) return 0;
|
|
|
return Math.round((playlistInfo.spotify_matches / playlistInfo.spotify_total) * 100);
|
|
|
}
|
|
|
|
|
|
async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) {
|
|
|
// Rehydrate a YouTube playlist's discovery modal state (similar to rehydrateModal)
|
|
|
const urlHash = playlistInfo.url_hash;
|
|
|
const playlistName = playlistInfo.playlist_name;
|
|
|
const phase = playlistInfo.phase;
|
|
|
|
|
|
console.log(`💧 Rehydrating YouTube playlist "${playlistName}" (Phase: ${phase}) - User requested: ${userRequested}`);
|
|
|
|
|
|
try {
|
|
|
// First, ensure the card exists (create from backend if needed)
|
|
|
if (!youtubePlaylistStates[urlHash] || !youtubePlaylistStates[urlHash].cardElement) {
|
|
|
console.log(`🃏 Creating missing YouTube card for rehydration: ${playlistName}`);
|
|
|
|
|
|
// Since playlistInfo from active processes doesn't have full playlist data,
|
|
|
// we need to fetch it from the backend first
|
|
|
try {
|
|
|
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
|
|
|
if (stateResponse.ok) {
|
|
|
const fullPlaylistState = await stateResponse.json();
|
|
|
createYouTubeCardFromBackendState(fullPlaylistState);
|
|
|
} else {
|
|
|
console.error(`❌ Could not fetch full playlist state for card creation: ${playlistName}`);
|
|
|
return; // Can't create card without playlist data
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error(`❌ Error fetching playlist state for card creation: ${error.message}`);
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Fetch full state from backend to get discovery results
|
|
|
let fullState = null;
|
|
|
if (phase !== 'fresh' && phase !== 'discovering') {
|
|
|
try {
|
|
|
console.log(`🔍 Fetching full backend state for: ${playlistName}`);
|
|
|
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
|
|
|
if (stateResponse.ok) {
|
|
|
fullState = await stateResponse.json();
|
|
|
console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.warn(`⚠️ Could not fetch full state for ${playlistName}:`, error.message);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Update local state to match backend
|
|
|
const state = youtubePlaylistStates[urlHash];
|
|
|
state.phase = phase;
|
|
|
state.discoveryProgress = playlistInfo.discovery_progress;
|
|
|
state.spotifyMatches = playlistInfo.spotify_matches;
|
|
|
state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id;
|
|
|
|
|
|
// Restore discovery results if we have them
|
|
|
if (fullState && fullState.discovery_results) {
|
|
|
state.discoveryResults = fullState.discovery_results;
|
|
|
state.syncPlaylistId = fullState.sync_playlist_id;
|
|
|
state.syncProgress = fullState.sync_progress || {};
|
|
|
console.log(`✅ Restored ${state.discoveryResults.length} discovery results from backend`);
|
|
|
|
|
|
// Update modal if it already exists
|
|
|
const existingModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
|
|
|
if (existingModal && !existingModal.classList.contains('hidden')) {
|
|
|
console.log(`🔄 Refreshing existing modal with restored discovery results`);
|
|
|
refreshYouTubeDiscoveryModalTable(urlHash);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Update card display
|
|
|
updateYouTubeCardPhase(urlHash, phase);
|
|
|
updateYouTubeCardProgress(urlHash, playlistInfo);
|
|
|
|
|
|
// Handle active polling resumption
|
|
|
if (phase === 'discovering') {
|
|
|
console.log(`🔍 Resuming discovery polling for: ${playlistName}`);
|
|
|
startYouTubeDiscoveryPolling(urlHash);
|
|
|
} else if (phase === 'syncing') {
|
|
|
console.log(`🔄 Resuming sync polling for: ${playlistName}`);
|
|
|
startYouTubeSyncPolling(urlHash);
|
|
|
}
|
|
|
|
|
|
// Open modal if user requested
|
|
|
if (userRequested) {
|
|
|
switch (phase) {
|
|
|
case 'discovering':
|
|
|
case 'discovered':
|
|
|
case 'syncing':
|
|
|
case 'sync_complete':
|
|
|
openYouTubeDiscoveryModal(urlHash);
|
|
|
break;
|
|
|
case 'downloading':
|
|
|
case 'download_complete':
|
|
|
// Open download modal if we have the converted playlist ID
|
|
|
if (playlistInfo.converted_spotify_playlist_id) {
|
|
|
await openDownloadMissingModal(playlistInfo.converted_spotify_playlist_id);
|
|
|
}
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
console.log(`✅ Successfully rehydrated YouTube playlist: ${playlistName}`);
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error(`❌ Error rehydrating YouTube playlist "${playlistName}":`, error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function removeYouTubePlaylistFromBackend(event, urlHash) {
|
|
|
// Remove YouTube playlist from backend storage and update UI
|
|
|
event.stopPropagation(); // Prevent card click
|
|
|
|
|
|
const state = youtubePlaylistStates[urlHash];
|
|
|
if (!state) return;
|
|
|
|
|
|
const playlistName = state.playlist.name;
|
|
|
|
|
|
try {
|
|
|
console.log(`🗑️ Removing YouTube playlist from backend: ${playlistName}`);
|
|
|
|
|
|
const response = await fetch(`/api/youtube/delete/${urlHash}`, {
|
|
|
method: 'DELETE'
|
|
|
});
|
|
|
|
|
|
if (!response.ok) {
|
|
|
const error = await response.json();
|
|
|
throw new Error(error.error || 'Failed to delete playlist');
|
|
|
}
|
|
|
|
|
|
// Remove card from UI
|
|
|
if (state.cardElement) {
|
|
|
state.cardElement.remove();
|
|
|
}
|
|
|
|
|
|
// Remove from client state
|
|
|
delete youtubePlaylistStates[urlHash];
|
|
|
|
|
|
// Stop any active polling
|
|
|
if (activeYouTubePollers[urlHash]) {
|
|
|
clearInterval(activeYouTubePollers[urlHash]);
|
|
|
delete activeYouTubePollers[urlHash];
|
|
|
}
|
|
|
|
|
|
// Close discovery modal if open
|
|
|
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
|
|
|
if (modal) {
|
|
|
modal.remove();
|
|
|
}
|
|
|
|
|
|
// Show placeholder if no cards left
|
|
|
const container = document.getElementById('youtube-playlist-container');
|
|
|
const cards = container.querySelectorAll('.youtube-playlist-card');
|
|
|
if (cards.length === 0) {
|
|
|
container.innerHTML = '<div class="youtube-playlist-placeholder">No YouTube playlists added yet. Parse a YouTube playlist URL above to get started!</div>';
|
|
|
}
|
|
|
|
|
|
showToast(`Removed "${playlistName}" from backend storage`, 'success');
|
|
|
console.log(`✅ Successfully removed YouTube playlist: ${playlistName}`);
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error(`❌ Error removing YouTube playlist "${playlistName}":`, error);
|
|
|
showToast(`Error removing playlist: ${error.message}`, 'error');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function loadSpotifyPlaylists() {
|
|
|
const container = document.getElementById('spotify-playlist-container');
|
|
|
const refreshBtn = document.getElementById('spotify-refresh-btn');
|
|
|
|
|
|
container.innerHTML = `<div class="playlist-placeholder">🔄 Loading playlists...</div>`;
|
|
|
refreshBtn.disabled = true;
|
|
|
refreshBtn.textContent = '🔄 Loading...';
|
|
|
|
|
|
try {
|
|
|
const response = await fetch('/api/spotify/playlists');
|
|
|
if (!response.ok) {
|
|
|
const error = await response.json();
|
|
|
throw new Error(error.error || 'Failed to fetch playlists');
|
|
|
}
|
|
|
spotifyPlaylists = await response.json();
|
|
|
renderSpotifyPlaylists();
|
|
|
spotifyPlaylistsLoaded = true;
|
|
|
|
|
|
await checkForActiveProcesses();
|
|
|
|
|
|
} catch (error) {
|
|
|
container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${error.message}</div>`;
|
|
|
showToast(`Error loading playlists: ${error.message}`, 'error');
|
|
|
} finally {
|
|
|
refreshBtn.disabled = false;
|
|
|
refreshBtn.textContent = '🔄 Refresh';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function renderSpotifyPlaylists() {
|
|
|
const container = document.getElementById('spotify-playlist-container');
|
|
|
if (spotifyPlaylists.length === 0) {
|
|
|
container.innerHTML = `<div class="playlist-placeholder">No Spotify playlists found.</div>`;
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
container.innerHTML = spotifyPlaylists.map(p => {
|
|
|
let statusClass = 'status-never-synced';
|
|
|
if (p.sync_status.startsWith('Synced')) statusClass = 'status-synced';
|
|
|
if (p.sync_status === 'Needs Sync') statusClass = 'status-needs-sync';
|
|
|
|
|
|
// This HTML structure creates the interactive playlist cards
|
|
|
return `
|
|
|
<div class="playlist-card" data-playlist-id="${p.id}" onclick="togglePlaylistSelection(event)">
|
|
|
<div class="playlist-card-main">
|
|
|
<div class="playlist-card-content">
|
|
|
<div class="playlist-card-name">${escapeHtml(p.name)}</div>
|
|
|
<div class="playlist-card-info">
|
|
|
<span>${p.track_count} tracks</span> •
|
|
|
<span class="playlist-card-status ${statusClass}">${p.sync_status}</span>
|
|
|
</div>
|
|
|
<div class="sync-progress-indicator" id="progress-${p.id}"></div>
|
|
|
</div>
|
|
|
<div class="playlist-card-actions">
|
|
|
<button id="action-btn-${p.id}" onclick="openPlaylistDetailsModal(event, '${p.id}')">Sync / Download</button>
|
|
|
<button id="progress-btn-${p.id}" class="view-progress-btn hidden" onclick="handleViewProgressClick(event, '${p.id}')">
|
|
|
View Progress
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
}).join('');
|
|
|
}
|
|
|
|
|
|
function handleViewProgressClick(event, playlistId) {
|
|
|
event.stopPropagation(); // Prevent the card selection from toggling
|
|
|
const process = activeDownloadProcesses[playlistId];
|
|
|
|
|
|
if (process && process.modalElement) {
|
|
|
// If a process is active, just show its modal
|
|
|
console.log(`Re-opening active download modal for playlist ${playlistId}`);
|
|
|
process.modalElement.style.display = 'flex';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function updatePlaylistCardUI(playlistId) {
|
|
|
const process = activeDownloadProcesses[playlistId];
|
|
|
const progressBtn = document.getElementById(`progress-btn-${playlistId}`);
|
|
|
const actionBtn = document.getElementById(`action-btn-${playlistId}`);
|
|
|
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
|
|
|
|
|
|
if (!progressBtn || !actionBtn) return;
|
|
|
|
|
|
if (process && process.status === 'running') {
|
|
|
// A process is running: show the progress button
|
|
|
progressBtn.classList.remove('hidden');
|
|
|
progressBtn.textContent = 'View Progress';
|
|
|
progressBtn.style.backgroundColor = ''; // Reset any custom styling
|
|
|
actionBtn.textContent = '📥 Downloading...';
|
|
|
actionBtn.disabled = true;
|
|
|
|
|
|
// Remove completion styling from card
|
|
|
if (card) card.classList.remove('download-complete');
|
|
|
|
|
|
} else if (process && process.status === 'complete') {
|
|
|
// Process completed: show "ready for review" indicator
|
|
|
progressBtn.classList.remove('hidden');
|
|
|
progressBtn.textContent = '📋 View Results';
|
|
|
progressBtn.style.backgroundColor = '#28a745'; // Green success color
|
|
|
progressBtn.style.color = 'white';
|
|
|
actionBtn.textContent = '✅ Ready for Review';
|
|
|
actionBtn.disabled = false; // Allow clicking to see results
|
|
|
|
|
|
// Add completion styling to card
|
|
|
if (card) card.classList.add('download-complete');
|
|
|
|
|
|
} else {
|
|
|
// No process or it's been cleaned up: normal state
|
|
|
progressBtn.classList.add('hidden');
|
|
|
progressBtn.style.backgroundColor = ''; // Reset styling
|
|
|
progressBtn.style.color = ''; // Reset styling
|
|
|
actionBtn.textContent = 'Sync / Download';
|
|
|
actionBtn.disabled = false;
|
|
|
|
|
|
// Remove completion styling from card
|
|
|
if (card) card.classList.remove('download-complete');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function cleanupDownloadProcess(playlistId) {
|
|
|
const process = activeDownloadProcesses[playlistId];
|
|
|
if (!process) return;
|
|
|
|
|
|
console.log(`🧹 Cleaning up download process for playlist ${playlistId}`);
|
|
|
|
|
|
// Stop any active polling first
|
|
|
if (process.poller) {
|
|
|
console.log(`🛑 Stopping individual polling for ${playlistId}`);
|
|
|
clearInterval(process.poller);
|
|
|
process.poller = null;
|
|
|
}
|
|
|
|
|
|
// Mark process as no longer running
|
|
|
if (process.status === 'running') {
|
|
|
process.status = 'complete';
|
|
|
}
|
|
|
|
|
|
// If the process has a batchId, tell the server to clean it up.
|
|
|
if (process.batchId) {
|
|
|
try {
|
|
|
console.log(`🚀 Sending cleanup request to server for batch: ${process.batchId}`);
|
|
|
const response = await fetch('/api/playlists/cleanup_batch', {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({ batch_id: process.batchId })
|
|
|
});
|
|
|
|
|
|
// Handle deferred cleanup (202 = wishlist processing in progress)
|
|
|
if (response.status === 202) {
|
|
|
console.log(`⏳ Wishlist processing in progress for batch ${process.batchId}, will retry cleanup in 2s...`);
|
|
|
// Retry cleanup after delay to allow wishlist processing to complete
|
|
|
setTimeout(async () => {
|
|
|
try {
|
|
|
await fetch('/api/playlists/cleanup_batch', {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({ batch_id: process.batchId })
|
|
|
});
|
|
|
console.log(`✅ Delayed cleanup completed for batch: ${process.batchId}`);
|
|
|
} catch (error) {
|
|
|
console.warn(`⚠️ Delayed cleanup failed:`, error);
|
|
|
}
|
|
|
}, 2000); // 2 second delay
|
|
|
} else {
|
|
|
console.log(`✅ Server cleanup completed for batch: ${process.batchId}`);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.warn(`⚠️ Failed to send cleanup request to server:`, error);
|
|
|
// Don't show toast for cleanup failures - they're not user-facing
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Remove modal from DOM
|
|
|
if (process.modalElement && process.modalElement.parentElement) {
|
|
|
process.modalElement.parentElement.removeChild(process.modalElement);
|
|
|
}
|
|
|
|
|
|
// Remove from client-side global state
|
|
|
delete activeDownloadProcesses[playlistId];
|
|
|
|
|
|
// Check if global polling should be stopped
|
|
|
checkAndCleanupGlobalPolling();
|
|
|
|
|
|
// Restore card UI (only for non-wishlist playlists)
|
|
|
if (playlistId !== 'wishlist') {
|
|
|
updatePlaylistCardUI(playlistId);
|
|
|
}
|
|
|
updateRefreshButtonState(); // Now safe since hasActiveOperations() excludes wishlist
|
|
|
}
|
|
|
|
|
|
function togglePlaylistSelection(event) {
|
|
|
const card = event.currentTarget;
|
|
|
const playlistId = card.dataset.playlistId;
|
|
|
|
|
|
// Don't toggle if clicking the button
|
|
|
if (event.target.tagName === 'BUTTON') return;
|
|
|
|
|
|
const isSelected = !card.classList.contains('selected');
|
|
|
card.classList.toggle('selected', isSelected);
|
|
|
|
|
|
if (isSelected) {
|
|
|
selectedPlaylists.add(playlistId);
|
|
|
} else {
|
|
|
selectedPlaylists.delete(playlistId);
|
|
|
}
|
|
|
updateSyncActionsUI();
|
|
|
}
|
|
|
|
|
|
function updateSyncActionsUI() {
|
|
|
// If sequential sync is running, let the manager handle UI updates
|
|
|
if (sequentialSyncManager && sequentialSyncManager.isRunning) {
|
|
|
sequentialSyncManager.updateUI();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const selectionInfo = document.getElementById('selection-info');
|
|
|
const startSyncBtn = document.getElementById('start-sync-btn');
|
|
|
const count = selectedPlaylists.size;
|
|
|
|
|
|
if (count === 0) {
|
|
|
if (selectionInfo) selectionInfo.textContent = 'Select playlists to sync';
|
|
|
if (startSyncBtn) startSyncBtn.disabled = true;
|
|
|
} else {
|
|
|
if (selectionInfo) selectionInfo.textContent = `${count} playlist${count > 1 ? 's' : ''} selected`;
|
|
|
if (startSyncBtn) startSyncBtn.disabled = false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function openPlaylistDetailsModal(event, playlistId) {
|
|
|
event.stopPropagation();
|
|
|
|
|
|
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
|
|
|
if (!playlist) return;
|
|
|
|
|
|
showLoadingOverlay(`Loading playlist: ${playlist.name}...`);
|
|
|
|
|
|
try {
|
|
|
// --- CACHING LOGIC START ---
|
|
|
if (playlistTrackCache[playlistId]) {
|
|
|
console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`);
|
|
|
// Use the cached tracks instead of fetching
|
|
|
const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] };
|
|
|
showPlaylistDetailsModal(fullPlaylist);
|
|
|
} else {
|
|
|
console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`);
|
|
|
// Fetch from the server if not in cache
|
|
|
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
|
|
|
const fullPlaylist = await response.json();
|
|
|
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
|
|
|
|
|
|
// Store the fetched tracks in the cache
|
|
|
playlistTrackCache[playlistId] = fullPlaylist.tracks;
|
|
|
console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`);
|
|
|
|
|
|
// Auto-mirror this Spotify playlist
|
|
|
mirrorPlaylist('spotify', playlistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({
|
|
|
track_name: t.name, artist_name: (t.artists && t.artists[0]) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : '',
|
|
|
album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '',
|
|
|
duration_ms: t.duration_ms || 0,
|
|
|
image_url: t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null,
|
|
|
source_track_id: t.id || t.spotify_track_id || ''
|
|
|
})), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url });
|
|
|
|
|
|
showPlaylistDetailsModal(fullPlaylist);
|
|
|
}
|
|
|
// --- CACHING LOGIC END ---
|
|
|
|
|
|
} catch (error) {
|
|
|
showToast(`Error: ${error.message}`, 'error');
|
|
|
} finally {
|
|
|
hideLoadingOverlay();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function showPlaylistDetailsModal(playlist) {
|
|
|
// Create modal if it doesn't exist
|
|
|
let modal = document.getElementById('playlist-details-modal');
|
|
|
if (!modal) {
|
|
|
modal = document.createElement('div');
|
|
|
modal.id = 'playlist-details-modal';
|
|
|
modal.className = 'modal-overlay';
|
|
|
document.body.appendChild(modal);
|
|
|
}
|
|
|
|
|
|
// Check if there's a completed download missing tracks process for this playlist
|
|
|
const activeProcess = activeDownloadProcesses[playlist.id];
|
|
|
const hasCompletedProcess = activeProcess && activeProcess.status === 'complete';
|
|
|
|
|
|
// Check if sync is currently running for this playlist
|
|
|
const isSyncing = !!activeSyncPollers[playlist.id];
|
|
|
|
|
|
modal.innerHTML = `
|
|
|
<div class="modal-container playlist-modal">
|
|
|
<div class="playlist-modal-header">
|
|
|
<div class="playlist-header-content">
|
|
|
<h2>${escapeHtml(playlist.name)}</h2>
|
|
|
<div class="playlist-quick-info">
|
|
|
<span class="playlist-track-count">${playlist.track_count} tracks</span>
|
|
|
<span class="playlist-owner">by ${escapeHtml(playlist.owner)}</span>
|
|
|
</div>
|
|
|
<!-- Sync status display (hidden by default, matches GUI) -->
|
|
|
<div class="playlist-modal-sync-status" id="modal-sync-status-${playlist.id}" style="display: none;">
|
|
|
<span class="sync-stat total-tracks">♪ <span id="modal-total-${playlist.id}">0</span></span>
|
|
|
<span class="sync-separator">/</span>
|
|
|
<span class="sync-stat matched-tracks">✓ <span id="modal-matched-${playlist.id}">0</span></span>
|
|
|
<span class="sync-separator">/</span>
|
|
|
<span class="sync-stat failed-tracks">✗ <span id="modal-failed-${playlist.id}">0</span></span>
|
|
|
<span class="sync-stat percentage">(<span id="modal-percentage-${playlist.id}">0</span>%)</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
<span class="playlist-modal-close" onclick="closePlaylistDetailsModal()">×</span>
|
|
|
</div>
|
|
|
|
|
|
<div class="playlist-modal-body">
|
|
|
${playlist.description ? `<div class="playlist-description">${escapeHtml(playlist.description)}</div>` : ''}
|
|
|
|
|
|
<div class="playlist-tracks-container">
|
|
|
<div class="playlist-tracks-list">
|
|
|
${playlist.tracks.map((track, index) => `
|
|
|
<div class="playlist-track-item">
|
|
|
<span class="playlist-track-number">${index + 1}</span>
|
|
|
<div class="playlist-track-info">
|
|
|
<div class="playlist-track-name">${escapeHtml(track.name)}</div>
|
|
|
<div class="playlist-track-artists">${formatArtists(track.artists)}</div>
|
|
|
</div>
|
|
|
<div class="playlist-track-duration">${formatDuration(track.duration_ms)}</div>
|
|
|
</div>
|
|
|
`).join('')}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div class="playlist-modal-footer">
|
|
|
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closePlaylistDetailsModal()">Close</button>
|
|
|
<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="openDownloadMissingModal('${playlist.id}')">
|
|
|
${hasCompletedProcess
|
|
|
? '📊 View Download Results'
|
|
|
: '📥 Download Missing Tracks'}
|
|
|
</button>
|
|
|
<select id="sync-mode-${playlist.id}" class="playlist-modal-sync-mode" title="Replace overwrites the server playlist; Append only adds new tracks (preserves user-added)" ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>
|
|
|
<option value="replace" selected>Replace</option>
|
|
|
<option value="append">Append only</option>
|
|
|
</select>
|
|
|
<button id="sync-btn-${playlist.id}" class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSync('${playlist.id}')" ${isSyncing ? 'disabled' : ''} ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>${isSyncing ? '⏳ Syncing...' : 'Sync Playlist'}</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
modal.style.display = 'flex';
|
|
|
}
|
|
|
|
|
|
function closePlaylistDetailsModal() {
|
|
|
const modal = document.getElementById('playlist-details-modal');
|
|
|
if (modal) {
|
|
|
modal.style.display = 'none';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function formatDuration(ms) {
|
|
|
const minutes = Math.floor(ms / 60000);
|
|
|
const seconds = Math.floor((ms % 60000) / 1000);
|
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
|
}
|
|
|
|
|
|
// ===============================
|
|
|
// DOWNLOAD MISSING TRACKS MODAL
|
|
|
// ===============================
|
|
|
|
|
|
let activeAnalysisTaskId = null;
|
|
|
let currentPlaylistTracks = [];
|
|
|
let analysisResults = [];
|
|
|
let missingTracks = [];
|
|
|
|
|
|
// New variables for enhanced modal functionality
|
|
|
let currentDownloadBatchId = null;
|
|
|
|
|
|
// ===============================
|
|
|
// HERO SECTION HELPER FUNCTIONS
|
|
|
// ===============================
|
|
|
|
|
|
/**
|
|
|
* Generate hero section HTML for download missing tracks modal
|
|
|
* Context-aware display based on available data
|
|
|
*/
|
|
|
function generateDownloadModalHeroSection(context) {
|
|
|
const { type, playlist, artist, album, trackCount } = context;
|
|
|
|
|
|
let heroContent = '';
|
|
|
let heroBackgroundImage = '';
|
|
|
|
|
|
switch (type) {
|
|
|
case 'album':
|
|
|
case 'artist_album':
|
|
|
// Artist/album context - show artist + album images
|
|
|
const artistImage = artist?.image_url || artist?.images?.[0]?.url;
|
|
|
const albumImage = album?.image_url || album?.images?.[0]?.url;
|
|
|
|
|
|
// Use album image as background if available
|
|
|
if (albumImage) {
|
|
|
heroBackgroundImage = `<div class="download-missing-modal-hero-bg" style="background-image: url('${albumImage}');"></div>`;
|
|
|
}
|
|
|
|
|
|
heroContent = `
|
|
|
<div class="download-missing-modal-hero-content">
|
|
|
<div class="download-missing-modal-hero-images">
|
|
|
${artistImage ? `<img class="download-missing-modal-hero-image artist" src="${artistImage}" alt="${escapeHtml(artist.name)}">` : ''}
|
|
|
${albumImage ? `<img class="download-missing-modal-hero-image album" src="${albumImage}" alt="${escapeHtml(album.name)}">` : ''}
|
|
|
</div>
|
|
|
<div class="download-missing-modal-hero-metadata">
|
|
|
<h1 class="download-missing-modal-hero-title">${escapeHtml(album.name || 'Unknown Album')}</h1>
|
|
|
<div class="download-missing-modal-hero-subtitle">by <a href="#" class="hero-artist-link" onclick="event.preventDefault();_navigateToArtistFromModal('${escapeHtml(artist.id || '')}','${escapeForInlineJs(artist.name || '')}','${escapeHtml(artist.image_url || '')}','${escapeHtml(artist.source || '')}','${escapeHtml(context.playlistId || '')}')">${escapeHtml(artist.name || 'Unknown Artist')}</a></div>
|
|
|
<div class="download-missing-modal-hero-details">
|
|
|
<span class="download-missing-modal-hero-detail">${album.album_type || 'Album'}</span>
|
|
|
<span class="download-missing-modal-hero-detail">${trackCount} tracks</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
break;
|
|
|
|
|
|
case 'playlist':
|
|
|
// Playlist context - show playlist info
|
|
|
heroContent = `
|
|
|
<div class="download-missing-modal-hero-content">
|
|
|
<div class="download-missing-modal-hero-icon">🎵</div>
|
|
|
<div class="download-missing-modal-hero-metadata">
|
|
|
<h1 class="download-missing-modal-hero-title">${escapeHtml(playlist.name)}</h1>
|
|
|
<div class="download-missing-modal-hero-subtitle">by ${escapeHtml(playlist.owner || 'Spotify')}</div>
|
|
|
<div class="download-missing-modal-hero-details">
|
|
|
<span class="download-missing-modal-hero-detail">Playlist</span>
|
|
|
<span class="download-missing-modal-hero-detail">${trackCount} tracks</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
break;
|
|
|
|
|
|
case 'wishlist':
|
|
|
// Wishlist context - show wishlist icon
|
|
|
heroContent = `
|
|
|
<div class="download-missing-modal-hero-content">
|
|
|
<div class="download-missing-modal-hero-icon">👁️</div>
|
|
|
<div class="download-missing-modal-hero-metadata">
|
|
|
<h1 class="download-missing-modal-hero-title">Wishlist</h1>
|
|
|
<div class="download-missing-modal-hero-subtitle">From watched artists</div>
|
|
|
<div class="download-missing-modal-hero-details">
|
|
|
<span class="download-missing-modal-hero-detail">Wishlist</span>
|
|
|
<span class="download-missing-modal-hero-detail">${trackCount} tracks</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
// Fallback - basic display
|
|
|
heroContent = `
|
|
|
<div class="download-missing-modal-hero-content">
|
|
|
<div class="download-missing-modal-hero-icon">📥</div>
|
|
|
<div class="download-missing-modal-hero-metadata">
|
|
|
<h1 class="download-missing-modal-hero-title">Download Missing Tracks</h1>
|
|
|
<div class="download-missing-modal-hero-subtitle">${trackCount} tracks</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
break;
|
|
|
}
|
|
|
|
|
|
return `
|
|
|
<div class="download-missing-modal-hero">
|
|
|
${heroBackgroundImage}
|
|
|
${heroContent}
|
|
|
<div class="download-dashboard-stats">
|
|
|
<div class="dashboard-stat stat-total">
|
|
|
<div class="dashboard-stat-number" id="stat-total-${context.playlistId}">${context.trackCount}</div>
|
|
|
<div class="dashboard-stat-label">Total</div>
|
|
|
</div>
|
|
|
<div class="dashboard-stat stat-found">
|
|
|
<div class="dashboard-stat-number" id="stat-found-${context.playlistId}">-</div>
|
|
|
<div class="dashboard-stat-label">Found</div>
|
|
|
</div>
|
|
|
<div class="dashboard-stat stat-missing">
|
|
|
<div class="dashboard-stat-number" id="stat-missing-${context.playlistId}">-</div>
|
|
|
<div class="dashboard-stat-label">Missing</div>
|
|
|
</div>
|
|
|
<div class="dashboard-stat stat-downloaded">
|
|
|
<div class="dashboard-stat-number" id="stat-downloaded-${context.playlistId}">0</div>
|
|
|
<div class="dashboard-stat-label">Downloaded</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="download-missing-modal-header-actions">
|
|
|
<span class="download-missing-modal-close" onclick="closeDownloadMissingModal('${context.playlistId || 'unknown'}')">×</span>
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
let modalDownloadPoller = null;
|
|
|
let currentModalPlaylistId = null;
|
|
|
|
|
|
// PHASE 2: Local cancelled track management (GUI PARITY)
|
|
|
let cancelledTracks = new Set(); // Track cancelled track indices like GUI's cancelled_tracks
|
|
|
|
|
|
const TRACK_RENDER_BATCH_SIZE = 100;
|
|
|
|
|
|
function applyProgressiveTrackRendering(playlistId, totalTrackCount) {
|
|
|
if (totalTrackCount <= TRACK_RENDER_BATCH_SIZE) return;
|
|
|
|
|
|
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
|
|
|
if (!modal) return;
|
|
|
|
|
|
const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`);
|
|
|
if (!tbody) return;
|
|
|
|
|
|
const rows = tbody.querySelectorAll('tr[data-track-index]');
|
|
|
if (rows.length <= TRACK_RENDER_BATCH_SIZE) return;
|
|
|
|
|
|
// Hide rows beyond first batch
|
|
|
for (let i = TRACK_RENDER_BATCH_SIZE; i < rows.length; i++) {
|
|
|
rows[i].classList.add('hidden');
|
|
|
}
|
|
|
|
|
|
let revealedCount = TRACK_RENDER_BATCH_SIZE;
|
|
|
|
|
|
// Append indicator into .download-tracks-title
|
|
|
const titleEl = modal.querySelector('.download-tracks-title');
|
|
|
if (titleEl) {
|
|
|
const indicator = document.createElement('span');
|
|
|
indicator.className = 'track-render-indicator';
|
|
|
indicator.id = `track-render-indicator-${playlistId}`;
|
|
|
indicator.textContent = `Showing ${revealedCount} of ${totalTrackCount} tracks`;
|
|
|
titleEl.appendChild(indicator);
|
|
|
}
|
|
|
|
|
|
// Scroll listener on table container
|
|
|
const container = modal.querySelector('.download-tracks-table-container');
|
|
|
if (!container) return;
|
|
|
|
|
|
container.addEventListener('scroll', function onScroll() {
|
|
|
const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
|
|
if (scrollBottom > 200) return;
|
|
|
if (revealedCount >= rows.length) return;
|
|
|
|
|
|
const nextEnd = Math.min(revealedCount + TRACK_RENDER_BATCH_SIZE, rows.length);
|
|
|
for (let i = revealedCount; i < nextEnd; i++) {
|
|
|
rows[i].classList.remove('hidden');
|
|
|
}
|
|
|
revealedCount = nextEnd;
|
|
|
|
|
|
const indicator = document.getElementById(`track-render-indicator-${playlistId}`);
|
|
|
if (indicator) {
|
|
|
indicator.textContent = revealedCount >= rows.length
|
|
|
? `Showing all ${totalTrackCount} tracks`
|
|
|
: `Showing ${revealedCount} of ${totalTrackCount} tracks`;
|
|
|
}
|
|
|
|
|
|
if (revealedCount >= rows.length) {
|
|
|
container.removeEventListener('scroll', onScroll);
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
async function openDownloadMissingModal(playlistId) {
|
|
|
showLoadingOverlay('Loading playlist...');
|
|
|
|
|
|
// **NEW**: Check if a process is already active for this playlist
|
|
|
if (activeDownloadProcesses[playlistId]) {
|
|
|
console.log(`Modal for ${playlistId} already exists. Showing it.`);
|
|
|
closePlaylistDetailsModal(); // Close playlist details modal even when reusing existing modal
|
|
|
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';
|
|
|
}
|
|
|
hideLoadingOverlay();
|
|
|
return; // Don't create a new one
|
|
|
}
|
|
|
|
|
|
console.log(`📥 Opening Download Missing Tracks modal for playlist: ${playlistId}`);
|
|
|
|
|
|
closePlaylistDetailsModal();
|
|
|
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
|
|
|
if (!playlist) {
|
|
|
showToast('Could not find playlist data.', 'error');
|
|
|
hideLoadingOverlay();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
let tracks = playlistTrackCache[playlistId];
|
|
|
if (!tracks) {
|
|
|
try {
|
|
|
const fetchUrl = playlistId.startsWith('deezer_arl_')
|
|
|
? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}`
|
|
|
: `/api/spotify/playlist/${playlistId}`;
|
|
|
const response = await fetch(fetchUrl);
|
|
|
const fullPlaylist = await response.json();
|
|
|
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
|
|
|
tracks = fullPlaylist.tracks;
|
|
|
playlistTrackCache[playlistId] = tracks;
|
|
|
} catch (error) {
|
|
|
showToast(`Failed to fetch tracks: ${error.message}`, 'error');
|
|
|
hideLoadingOverlay();
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
currentPlaylistTracks = tracks;
|
|
|
currentModalPlaylistId = playlistId;
|
|
|
|
|
|
let modal = document.createElement('div');
|
|
|
modal.id = `download-missing-modal-${playlistId}`; // **NEW**: Unique ID
|
|
|
modal.className = 'download-missing-modal'; // **NEW**: Use class for styling
|
|
|
modal.style.display = 'none'; // Start hidden
|
|
|
document.body.appendChild(modal);
|
|
|
|
|
|
// **NEW**: Register the new process in our global state tracker
|
|
|
activeDownloadProcesses[playlistId] = {
|
|
|
status: 'idle', // idle, running, complete, cancelled
|
|
|
modalElement: modal,
|
|
|
poller: null,
|
|
|
batchId: null,
|
|
|
playlist: playlist,
|
|
|
tracks: tracks
|
|
|
};
|
|
|
|
|
|
// Generate hero section for playlist context
|
|
|
const heroContext = {
|
|
|
type: 'playlist',
|
|
|
playlist: playlist,
|
|
|
trackCount: tracks.length,
|
|
|
playlistId: playlistId
|
|
|
};
|
|
|
|
|
|
modal.innerHTML = `
|
|
|
<div class="download-missing-modal-content" data-context="playlist">
|
|
|
<div class="download-missing-modal-header">
|
|
|
${generateDownloadModalHeroSection(heroContext)}
|
|
|
</div>
|
|
|
|
|
|
<div class="download-missing-modal-body">
|
|
|
<div class="download-progress-section">
|
|
|
<div class="progress-item">
|
|
|
<div class="progress-label">
|
|
|
🔍 Library Analysis
|
|
|
<span id="analysis-progress-text-${playlistId}">Ready to start</span>
|
|
|
</div>
|
|
|
<div class="progress-bar">
|
|
|
<div class="progress-fill analysis" id="analysis-progress-fill-${playlistId}"></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="progress-item">
|
|
|
<div class="progress-label">
|
|
|
⏬ Downloads
|
|
|
<span id="download-progress-text-${playlistId}">Waiting for analysis</span>
|
|
|
</div>
|
|
|
<div class="progress-bar">
|
|
|
<div class="progress-fill download" id="download-progress-fill-${playlistId}"></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div class="download-tracks-section">
|
|
|
<div class="download-tracks-header">
|
|
|
<h3 class="download-tracks-title">📋 Track Analysis & Download Status</h3>
|
|
|
<span class="track-selection-count" id="track-selection-count-${playlistId}">${tracks.length} / ${tracks.length} tracks selected</span>
|
|
|
</div>
|
|
|
<div class="download-tracks-table-container">
|
|
|
<table class="download-tracks-table">
|
|
|
<thead>
|
|
|
<tr>
|
|
|
<th class="track-select-header">
|
|
|
<input type="checkbox" class="track-select-all"
|
|
|
id="select-all-${playlistId}" checked
|
|
|
onchange="toggleAllTrackSelections('${playlistId}', this.checked)">
|
|
|
</th>
|
|
|
<th>#</th>
|
|
|
<th>Track</th>
|
|
|
<th>Artist</th>
|
|
|
<th>Duration</th>
|
|
|
<th>Library Match</th>
|
|
|
<th>Download Status</th>
|
|
|
<th>Actions</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody id="download-tracks-tbody-${playlistId}">
|
|
|
${tracks.map((track, index) => `
|
|
|
<tr data-track-index="${index}">
|
|
|
<td class="track-select-cell">
|
|
|
<input type="checkbox" class="track-select-cb"
|
|
|
data-track-index="${index}" checked
|
|
|
onchange="updateTrackSelectionCount('${playlistId}')">
|
|
|
</td>
|
|
|
<td class="track-number">${index + 1}</td>
|
|
|
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
|
|
|
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
|
|
|
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
|
|
|
<td class="track-match-status match-checking" id="match-${playlistId}-${index}">🔍 Pending</td>
|
|
|
<td class="track-download-status" id="download-${playlistId}-${index}">-</td>
|
|
|
<td class="track-actions" id="actions-${playlistId}-${index}">-</td>
|
|
|
</tr>
|
|
|
`).join('')}
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div class="download-missing-modal-footer">
|
|
|
<div class="download-phase-controls">
|
|
|
<div class="force-download-toggle-container" style="margin-bottom: 0px; display: flex; flex-direction: column; gap: 8px; align-items: flex-start;">
|
|
|
<label class="force-download-toggle">
|
|
|
<input type="checkbox" id="force-download-all-${playlistId}">
|
|
|
<span>Force Download All</span>
|
|
|
</label>
|
|
|
<label class="force-download-toggle">
|
|
|
<input type="checkbox" id="playlist-folder-mode-${playlistId}">
|
|
|
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span>
|
|
|
</label>
|
|
|
</div>
|
|
|
<button class="download-control-btn primary" id="begin-analysis-btn-${playlistId}" onclick="startMissingTracksProcess('${playlistId}')">
|
|
|
Begin Analysis
|
|
|
</button>
|
|
|
<button class="download-control-btn" id="add-to-wishlist-btn-${playlistId}" onclick="addModalTracksToWishlist('${playlistId}')" style="background-color: #9333ea; color: white;">
|
|
|
Add to Wishlist
|
|
|
</button>
|
|
|
<button class="download-control-btn danger" id="cancel-all-btn-${playlistId}" onclick="cancelAllOperations('${playlistId}')" style="display: none;">
|
|
|
Cancel All
|
|
|
</button>
|
|
|
</div>
|
|
|
<div class="modal-close-section">
|
|
|
<button class="download-control-btn export" onclick="exportPlaylistAsM3U('${playlistId}')">
|
|
|
📋 Export as M3U
|
|
|
</button>
|
|
|
<button class="download-control-btn secondary" onclick="closeDownloadMissingModal('${playlistId}')">Close</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
applyProgressiveTrackRendering(playlistId, tracks.length);
|
|
|
modal.style.display = 'flex';
|
|
|
hideLoadingOverlay();
|
|
|
}
|
|
|
|
|
|
async function autoSavePlaylistM3U(playlistId) {
|
|
|
/**
|
|
|
* Automatically save M3U file server-side for playlist modals only.
|
|
|
* Albums are skipped — they're already grouped by media servers.
|
|
|
* The server checks the m3u_export.enabled setting before writing.
|
|
|
* Uses real DB file paths via /api/generate-playlist-m3u.
|
|
|
*/
|
|
|
const process = activeDownloadProcesses[playlistId];
|
|
|
if (!process || !process.tracks || process.tracks.length === 0) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
|
|
|
if (!modal) return;
|
|
|
|
|
|
// Skip M3U for non-playlist downloads — albums, singles, redownloads, etc.
|
|
|
const nonPlaylistPrefixes = [
|
|
|
'artist_album_', 'discover_album_', 'enhanced_search_album_', 'enhanced_search_track_',
|
|
|
'seasonal_album_', 'spotify_library_', 'beatport_release_', 'discover_cache_',
|
|
|
'issue_download_', 'library_redownload_', 'redownload_',
|
|
|
];
|
|
|
if (nonPlaylistPrefixes.some(p => playlistId.startsWith(p))) return;
|
|
|
|
|
|
const playlistName = process.playlist?.name || process.playlistName || 'Playlist';
|
|
|
const artistName = process.artist?.name || '';
|
|
|
const albumName = process.album?.name || '';
|
|
|
const releaseDate = process.album?.release_date || '';
|
|
|
const year = releaseDate ? releaseDate.substring(0, 4) : '';
|
|
|
|
|
|
try {
|
|
|
const response = await fetch('/api/generate-playlist-m3u', {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({
|
|
|
playlist_name: playlistName,
|
|
|
tracks: _extractM3UTracks(process.tracks),
|
|
|
context_type: 'playlist',
|
|
|
artist_name: artistName,
|
|
|
album_name: albumName,
|
|
|
year: year,
|
|
|
save_to_disk: true
|
|
|
})
|
|
|
});
|
|
|
|
|
|
if (response.ok) {
|
|
|
console.log(`✅ Auto-saved M3U for playlist: ${playlistName}`);
|
|
|
} else {
|
|
|
console.warn(`⚠️ Failed to auto-save M3U for ${playlistName}`);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.debug('Auto-save M3U error (non-critical):', error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function generateM3UContent(playlistId) {
|
|
|
/**
|
|
|
* Generate M3U file content from modal data
|
|
|
* Shared between manual export and auto-save
|
|
|
*/
|
|
|
const process = activeDownloadProcesses[playlistId];
|
|
|
if (!process || !process.tracks || process.tracks.length === 0) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
const tracks = process.tracks;
|
|
|
const playlistName = process.playlist?.name || process.playlistName || 'Playlist';
|
|
|
|
|
|
// Generate M3U8 content with status information
|
|
|
let m3uContent = '#EXTM3U\n';
|
|
|
m3uContent += `#PLAYLIST:${playlistName}\n`;
|
|
|
m3uContent += `#GENERATED:${new Date().toISOString()}\n\n`;
|
|
|
|
|
|
let foundCount = 0;
|
|
|
let downloadedCount = 0;
|
|
|
let missingCount = 0;
|
|
|
|
|
|
tracks.forEach((track, index) => {
|
|
|
const durationSeconds = track.duration_ms ? Math.floor(track.duration_ms / 1000) : -1;
|
|
|
let artists = 'Unknown Artist';
|
|
|
if (Array.isArray(track.artists)) {
|
|
|
artists = track.artists.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : String(a)).filter(Boolean).join(', ') || 'Unknown Artist';
|
|
|
} else if (typeof track.artists === 'string') {
|
|
|
artists = track.artists;
|
|
|
} else if (track.artist) {
|
|
|
artists = typeof track.artist === 'object' ? (track.artist.name || 'Unknown Artist') : String(track.artist);
|
|
|
}
|
|
|
|
|
|
// Check library match status from the modal UI
|
|
|
const matchEl = document.getElementById(`match-${playlistId}-${index}`);
|
|
|
const downloadEl = document.getElementById(`download-${playlistId}-${index}`);
|
|
|
|
|
|
const isFoundInLibrary = matchEl && matchEl.textContent.includes('Found');
|
|
|
const isDownloaded = downloadEl && downloadEl.textContent.includes('Completed');
|
|
|
const isMissing = matchEl && matchEl.textContent.includes('Missing');
|
|
|
|
|
|
// Track status
|
|
|
let status = 'UNKNOWN';
|
|
|
if (isDownloaded) {
|
|
|
status = 'DOWNLOADED';
|
|
|
downloadedCount++;
|
|
|
} else if (isFoundInLibrary) {
|
|
|
status = 'FOUND_IN_LIBRARY';
|
|
|
foundCount++;
|
|
|
} else if (isMissing) {
|
|
|
status = 'MISSING';
|
|
|
missingCount++;
|
|
|
}
|
|
|
|
|
|
// Add track info
|
|
|
m3uContent += `#EXTINF:${durationSeconds},${artists} - ${track.name}\n`;
|
|
|
m3uContent += `#STATUS:${status}\n`;
|
|
|
|
|
|
// Generate file path
|
|
|
const sanitizedArtist = artists.replace(/[/\\?%*:|"<>]/g, '-');
|
|
|
const sanitizedTrack = track.name.replace(/[/\\?%*:|"<>]/g, '-');
|
|
|
|
|
|
if (isDownloaded || isFoundInLibrary) {
|
|
|
m3uContent += `${sanitizedArtist} - ${sanitizedTrack}.mp3\n\n`;
|
|
|
} else {
|
|
|
m3uContent += `# NOT AVAILABLE: ${sanitizedArtist} - ${sanitizedTrack}.mp3\n\n`;
|
|
|
}
|
|
|
});
|
|
|
|
|
|
// Add summary
|
|
|
m3uContent += `#SUMMARY\n`;
|
|
|
m3uContent += `#TOTAL_TRACKS:${tracks.length}\n`;
|
|
|
m3uContent += `#FOUND_IN_LIBRARY:${foundCount}\n`;
|
|
|
m3uContent += `#DOWNLOADED:${downloadedCount}\n`;
|
|
|
m3uContent += `#MISSING:${missingCount}\n`;
|
|
|
|
|
|
return m3uContent;
|
|
|
}
|
|
|
|
|
|
async function exportPlaylistAsM3U(playlistId) {
|
|
|
/**
|
|
|
* Export the tracks from the download missing tracks modal as an M3U playlist file.
|
|
|
* Downloads via browser AND saves server-side to the relevant folder (force=true).
|
|
|
* Uses real DB file paths via /api/generate-playlist-m3u.
|
|
|
*/
|
|
|
console.log(`📋 Exporting playlist ${playlistId} as M3U`);
|
|
|
|
|
|
const process = activeDownloadProcesses[playlistId];
|
|
|
if (!process || !process.tracks || process.tracks.length === 0) {
|
|
|
showToast('No tracks available to export', 'warning');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const playlistName = process.playlist?.name || process.playlistName || 'Playlist';
|
|
|
const albumPrefixes = ['artist_album_', 'discover_album_', 'enhanced_search_album_', 'seasonal_album_', 'spotify_library_', 'beatport_release_', 'discover_cache_'];
|
|
|
const isAlbumExport = albumPrefixes.some(p => playlistId.startsWith(p));
|
|
|
const releaseDate = process.album?.release_date || '';
|
|
|
const year = releaseDate ? releaseDate.substring(0, 4) : '';
|
|
|
|
|
|
let m3uContent, foundCount, missingCount;
|
|
|
try {
|
|
|
const response = await fetch('/api/generate-playlist-m3u', {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({
|
|
|
playlist_name: playlistName,
|
|
|
tracks: _extractM3UTracks(process.tracks),
|
|
|
context_type: isAlbumExport ? 'album' : 'playlist',
|
|
|
artist_name: process.artist?.name || '',
|
|
|
album_name: process.album?.name || '',
|
|
|
year: year,
|
|
|
save_to_disk: true,
|
|
|
force: true
|
|
|
})
|
|
|
});
|
|
|
const data = await response.json();
|
|
|
if (!data.success) throw new Error(data.error || 'Unknown error');
|
|
|
m3uContent = data.m3u_content;
|
|
|
foundCount = (data.stats?.found || 0) + (data.stats?.downloaded || 0);
|
|
|
missingCount = data.stats?.missing || 0;
|
|
|
} catch (error) {
|
|
|
showToast('Failed to generate M3U content', 'error');
|
|
|
console.error('M3U export error:', error);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Browser download
|
|
|
const blob = new Blob([m3uContent], { type: 'audio/x-mpegurl;charset=utf-8' });
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
const link = document.createElement('a');
|
|
|
link.href = url;
|
|
|
link.download = `${playlistName.replace(/[/\\?%*:|"<>]/g, '-')}.m3u`;
|
|
|
document.body.appendChild(link);
|
|
|
link.click();
|
|
|
document.body.removeChild(link);
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
showToast(`Exported M3U: ${foundCount} available, ${missingCount} missing`, 'success');
|
|
|
console.log(`✅ Exported M3U - Total: ${process.tracks.length}, Available: ${foundCount}, Missing: ${missingCount}`);
|
|
|
}
|
|
|
|
|
|
function _extractM3UTracks(tracks) {
|
|
|
/** Extract simplified track data for the /api/generate-playlist-m3u endpoint. */
|
|
|
return tracks.map(t => {
|
|
|
let artist = '';
|
|
|
if (Array.isArray(t.artists)) {
|
|
|
const first = t.artists[0];
|
|
|
artist = typeof first === 'object' ? (first.name || '') : String(first || '');
|
|
|
} else if (typeof t.artists === 'string') {
|
|
|
artist = t.artists;
|
|
|
} else if (t.artist) {
|
|
|
artist = typeof t.artist === 'object' ? (t.artist.name || '') : String(t.artist);
|
|
|
}
|
|
|
return { name: t.name || '', artist, duration_ms: t.duration_ms || 0 };
|
|
|
});
|
|
|
}
|
|
|
|
|
|
// ==================================================================================
|