// TIDAL PLAYLIST MANAGEMENT (YouTube-style cards with Tidal colors)
// ===================================================================
async function loadTidalPlaylists() {
const container = document.getElementById('tidal-playlist-container');
const refreshBtn = document.getElementById('tidal-refresh-btn');
container.innerHTML = `
π Loading Tidal playlists...
`;
refreshBtn.disabled = true;
refreshBtn.textContent = 'π Loading...';
try {
const response = await fetch('/api/tidal/playlists');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch Tidal playlists');
}
tidalPlaylists = await response.json();
renderTidalPlaylists();
tidalPlaylistsLoaded = true;
console.log(`π΅ Loaded ${tidalPlaylists.length} Tidal playlists`);
// Auto-mirror Tidal playlists: fetch tracks in background then mirror
// Cards render instantly from metadata; tracks load per-playlist without blocking UI
for (const p of tidalPlaylists) {
// Skip if already have tracks from a previous load
if (p.tracks && p.tracks.length > 0) {
mirrorPlaylist('tidal', p.id, p.name, p.tracks.map(t => ({
track_name: t.name || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artists || ''),
album_name: typeof t.album === 'string' ? t.album : '', duration_ms: t.duration_ms || 0,
source_track_id: t.id || ''
})), { owner: p.owner, image_url: p.image_url, description: p.description });
continue;
}
// Fetch tracks on-demand for this playlist
try {
const fullResp = await fetch(`/api/tidal/playlist/${p.id}`);
if (fullResp.ok) {
const fullData = await fullResp.json();
if (fullData.tracks && fullData.tracks.length > 0) {
p.tracks = fullData.tracks;
p.track_count = fullData.tracks.length;
// Update card track count in UI
const countEl = document.querySelector(`#tidal-card-${p.id} .playlist-card-track-count`);
if (countEl) countEl.textContent = `${fullData.tracks.length} tracks`;
// Mirror with full track data
mirrorPlaylist('tidal', p.id, p.name, fullData.tracks.map(t => ({
track_name: t.name || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artists || ''),
album_name: typeof t.album === 'string' ? t.album : '', duration_ms: t.duration_ms || 0,
source_track_id: t.id || ''
})), { owner: p.owner, image_url: p.image_url, description: p.description });
}
}
} catch (e) {
console.warn(`Failed to fetch tracks for Tidal playlist ${p.name}: ${e.message}`);
}
}
// Load and apply saved discovery states from backend (like YouTube)
await loadTidalPlaylistStatesFromBackend();
} catch (error) {
container.innerHTML = `β Error: ${error.message}
`;
showToast(`Error loading Tidal playlists: ${error.message}`, 'error');
} finally {
refreshBtn.disabled = false;
refreshBtn.textContent = 'π Refresh';
}
}
function renderTidalPlaylists() {
const container = document.getElementById('tidal-playlist-container');
if (tidalPlaylists.length === 0) {
container.innerHTML = `No Tidal playlists found.
`;
return;
}
container.innerHTML = tidalPlaylists.map(p => {
// Initialize state if not exists (fresh state like sync.py)
if (!tidalPlaylistStates[p.id]) {
tidalPlaylistStates[p.id] = {
phase: 'fresh',
playlist: p
};
}
return createTidalCard(p);
}).join('');
// Add click handlers to cards
tidalPlaylists.forEach(p => {
const card = document.getElementById(`tidal-card-${p.id}`);
if (card) {
card.addEventListener('click', () => handleTidalCardClick(p.id));
}
});
}
function createTidalCard(playlist) {
const state = tidalPlaylistStates[playlist.id];
const phase = state.phase;
// Get phase-specific button text (like YouTube cards)
let buttonText = getActionButtonText(phase);
let phaseText = getPhaseText(phase);
let phaseColor = getPhaseColor(phase);
return `
π΅
${escapeHtml(playlist.name)}
${playlist.track_count} tracks
${phaseText}
${buttonText}
`;
}
async function handleTidalCardClick(playlistId) {
// Robust state validation
const state = tidalPlaylistStates[playlistId];
if (!state) {
console.error(`β [Card Click] No state found for Tidal playlist: ${playlistId}`);
showToast('Playlist state not found - try refreshing the page', 'error');
return;
}
// Validate required state data
if (!state.playlist) {
console.error(`β [Card Click] No playlist data found for Tidal playlist: ${playlistId}`);
showToast('Playlist data missing - try refreshing the page', 'error');
return;
}
// Validate phase
if (!state.phase) {
console.warn(`β οΈ [Card Click] No phase set for Tidal playlist ${playlistId} - defaulting to 'fresh'`);
state.phase = 'fresh';
}
console.log(`π΅ [Card Click] Tidal card clicked: ${playlistId}, Phase: ${state.phase}`);
if (state.phase === 'fresh') {
// Fetch tracks if not yet loaded (metadata-only listing doesn't include them)
if (!state.playlist.tracks || state.playlist.tracks.length === 0) {
console.log(`π΅ Fetching tracks for Tidal playlist: ${state.playlist.name}`);
showLoadingOverlay(`Loading ${state.playlist.name}...`);
try {
const resp = await fetch(`/api/tidal/playlist/${playlistId}`);
if (resp.ok) {
const fullData = await resp.json();
if (fullData.tracks && fullData.tracks.length > 0) {
// Convert to Track-like objects for the discovery modal
state.playlist.tracks = fullData.tracks.map(t => ({
id: t.id, name: t.name, artists: t.artists || [],
album: t.album || '', duration_ms: t.duration_ms || 0,
track_number: t.track_number || 0
}));
// Update card count
const countEl = document.querySelector(`#tidal-card-${playlistId} .playlist-card-track-count`);
if (countEl) countEl.textContent = `${state.playlist.tracks.length} tracks`;
}
}
} catch (e) {
console.error(`Failed to fetch Tidal playlist tracks: ${e}`);
hideLoadingOverlay();
}
}
if (!state.playlist.tracks || state.playlist.tracks.length === 0) {
hideLoadingOverlay();
showToast('Could not load tracks for this playlist', 'error');
return;
}
hideLoadingOverlay();
console.log(`π΅ Ready with ${state.playlist.tracks.length} Tidal tracks for discovery`);
// Open discovery modal - phase will be updated when discovery actually starts
openTidalDiscoveryModal(playlistId, state.playlist);
} else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') {
// Reopen existing modal with preserved discovery results (like GUI sync.py)
console.log(`π΅ [Card Click] Opening Tidal discovery modal for ${state.phase} phase`);
// Validate that we have discovery results to show
if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) {
console.warn(`β οΈ [Card Click] Discovered phase but no discovery results found - attempting to reload from backend`);
// Try to fetch from backend as fallback
try {
const stateResponse = await fetch(`/api/tidal/state/${playlistId}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
if (fullState.discovery_results) {
// Merge backend state with current state
state.discovery_results = fullState.discovery_results;
state.spotify_matches = fullState.spotify_matches || state.spotify_matches;
state.discovery_progress = fullState.discovery_progress || state.discovery_progress;
tidalPlaylistStates[playlistId] = { ...tidalPlaylistStates[playlistId], ...state };
console.log(`β
[Card Click] Restored ${fullState.discovery_results.length} discovery results from backend`);
}
}
} catch (error) {
console.error(`β [Card Click] Failed to fetch discovery results from backend: ${error}`);
}
}
openTidalDiscoveryModal(playlistId, state.playlist);
} else if (state.phase === 'downloading' || state.phase === 'download_complete') {
// Open download modal if we have the converted playlist ID
if (state.convertedSpotifyPlaylistId) {
console.log(`π [Card Click] Opening download modal for Tidal playlist: ${state.playlist.name} (phase: ${state.phase})`);
// Check if modal already exists, if not create it
if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) {
const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId];
if (process.modalElement) {
console.log(`π± [Card Click] Showing existing download modal for ${state.phase} phase`);
process.modalElement.style.display = 'flex';
} else {
console.warn(`β οΈ [Card Click] Download process exists but modal element missing - rehydrating`);
await rehydrateTidalDownloadModal(playlistId, state);
}
} else {
// Need to create the download modal - fetch the discovery results
console.log(`π§ [Card Click] Rehydrating Tidal download modal for ${state.phase} phase`);
await rehydrateTidalDownloadModal(playlistId, state);
}
} else {
console.error('β [Card Click] No converted Spotify playlist ID found for Tidal download modal');
console.log('π [Card Click] Available state data:', Object.keys(state));
// Fallback: try to open discovery modal if we have discovery results
if (state.discovery_results && state.discovery_results.length > 0) {
console.log(`π [Card Click] Fallback: Opening discovery modal with ${state.discovery_results.length} results`);
openTidalDiscoveryModal(playlistId, state.playlist);
} else {
showToast('Unable to open download modal - missing playlist data', 'error');
}
}
}
}
async function rehydrateTidalDownloadModal(playlistId, state) {
try {
// Robust state validation for rehydration
if (!state || !state.playlist) {
console.error(`β [Rehydration] Invalid state data for Tidal playlist: ${playlistId}`);
showToast('Cannot open download modal - invalid playlist data', 'error');
return;
}
console.log(`π§ [Rehydration] Rehydrating Tidal download modal for: ${state.playlist.name}`);
// Get discovery results from backend if not already loaded
if (!state.discovery_results) {
console.log(`π Fetching discovery results from backend for Tidal playlist: ${playlistId}`);
const stateResponse = await fetch(`/api/tidal/state/${playlistId}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
state.discovery_results = fullState.discovery_results;
state.convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
state.download_process_id = fullState.download_process_id;
console.log(`β
Loaded ${fullState.discovery_results?.length || 0} discovery results from backend`);
} else {
console.error('β Failed to fetch Tidal discovery results from backend');
showToast('Error loading playlist data', 'error');
return;
}
}
// Extract Spotify tracks from discovery results
const spotifyTracks = [];
for (const result of state.discovery_results) {
if (result.spotify_data) {
spotifyTracks.push(result.spotify_data);
}
}
if (spotifyTracks.length === 0) {
console.error('β No Spotify tracks found for download modal');
showToast('No Spotify matches found for download', 'error');
return;
}
const virtualPlaylistId = state.convertedSpotifyPlaylistId;
const playlistName = state.playlist.name;
// Create the download modal
await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks);
// If we have a download process ID, set up the modal for the running state
if (state.download_process_id) {
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = state.phase === 'download_complete' ? 'complete' : 'running';
process.batchId = state.download_process_id;
// Update UI based on phase
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (state.phase === 'downloading') {
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for live updates
startModalDownloadPolling(virtualPlaylistId);
console.log(`π Started polling for active Tidal download: ${state.download_process_id}`);
} else if (state.phase === 'download_complete') {
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'none';
console.log(`β
Showing completed Tidal download results: ${state.download_process_id}`);
// For completed downloads, fetch the final results once to populate the modal
try {
const response = await fetch(`/api/playlists/${state.download_process_id}/download_status`);
if (response.ok) {
const data = await response.json();
if (data.phase === 'complete' && data.tasks) {
console.log(`π [Rehydration] Loading ${data.tasks.length} completed tasks for modal display`);
// Process the completed tasks to update modal display
updateCompletedModalResults(virtualPlaylistId, data);
} else {
console.warn(`β οΈ [Rehydration] Unexpected data from download_status: phase=${data.phase}, tasks=${data.tasks?.length || 0}`);
}
} else {
console.error(`β [Rehydration] Failed to fetch download status: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error(`β [Rehydration] Error fetching final results for completed download: ${error}`);
// Show a user-friendly message but still allow modal to open
showToast('Could not load download results - modal may show incomplete data', 'warning', 3000);
}
}
}
}
console.log(`β
Successfully rehydrated Tidal download modal for: ${state.playlist.name}`);
} catch (error) {
console.error(`β Error rehydrating Tidal download modal:`, error);
showToast('Error opening download modal', 'error');
}
}
function updateCompletedModalResults(playlistId, downloadData) {
/**
* Update a completed download modal with final results
* This reuses the existing status polling logic but applies it once for completed state
*/
console.log(`π [Completed Results] Updating modal ${playlistId} with final download results`);
// Validate input data
if (!downloadData || !downloadData.tasks) {
console.error(`β [Completed Results] Invalid download data for playlist ${playlistId}:`, downloadData);
return;
}
try {
// Update analysis progress to 100%
const analysisProgressFill = document.getElementById(`analysis-progress-fill-${playlistId}`);
const analysisProgressText = document.getElementById(`analysis-progress-text-${playlistId}`);
if (analysisProgressFill) analysisProgressFill.style.width = '100%';
if (analysisProgressText) analysisProgressText.textContent = 'Analysis complete!';
// Update analysis results and stats
if (downloadData.analysis_results) {
updateTrackAnalysisResults(playlistId, downloadData.analysis_results);
const foundCount = downloadData.analysis_results.filter(r => r.found).length;
const missingCount = downloadData.analysis_results.filter(r => !r.found).length;
const statFound = document.getElementById(`stat-found-${playlistId}`);
const statMissing = document.getElementById(`stat-missing-${playlistId}`);
if (statFound) statFound.textContent = foundCount;
if (statMissing) statMissing.textContent = missingCount;
}
// Process completed tasks to update individual track statuses
const missingTracks = (downloadData.analysis_results || []).filter(r => !r.found);
let completedCount = 0;
let failedOrCancelledCount = 0;
let notFoundCount = 0;
(downloadData.tasks || []).forEach(task => {
const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${task.track_index}"]`);
if (!row) return;
row.dataset.taskId = task.task_id;
const statusEl = document.getElementById(`download-${playlistId}-${task.track_index}`);
const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`);
let statusText = '';
switch (task.status) {
case 'pending': statusText = 'βΈοΈ Pending'; break;
case 'searching': statusText = 'π Searching...'; break;
case 'downloading': statusText = `β¬ Downloading... ${Math.round(task.progress || 0)}%`; break;
case 'post_processing': statusText = 'β Processing...'; break; // NEW VERIFICATION WORKFLOW
case 'completed': statusText = 'β
Completed'; completedCount++; break;
case 'not_found': statusText = 'π Not Found'; notFoundCount++; break;
case 'failed': statusText = 'β Failed'; failedOrCancelledCount++; break;
case 'cancelled': statusText = 'π« Cancelled'; failedOrCancelledCount++; break;
default: statusText = `βͺ ${task.status}`; break;
}
if (statusEl) {
statusEl.textContent = statusText;
if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) {
statusEl.classList.add('has-error-tooltip');
statusEl.dataset.errorMsg = task.error_message;
_ensureErrorTooltipListeners(statusEl);
}
if (task.status === 'not_found' && task.has_candidates) {
statusEl.classList.add('has-candidates');
statusEl.dataset.taskId = task.task_id;
_ensureCandidatesClickListener(statusEl);
}
}
if (actionsEl) actionsEl.innerHTML = '-'; // Remove action buttons for completed tasks
});
// Update download progress to final state
const totalFinished = completedCount + failedOrCancelledCount + notFoundCount;
const missingCount = missingTracks.length;
const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 100;
const downloadProgressFill = document.getElementById(`download-progress-fill-${playlistId}`);
const downloadProgressText = document.getElementById(`download-progress-text-${playlistId}`);
const statDownloaded = document.getElementById(`stat-downloaded-${playlistId}`);
if (downloadProgressFill) downloadProgressFill.style.width = `${progressPercent}%`;
if (downloadProgressText) downloadProgressText.textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`;
if (statDownloaded) statDownloaded.textContent = completedCount;
console.log(`β
[Completed Results] Updated modal with ${completedCount} completed, ${notFoundCount} not found, ${failedOrCancelledCount} failed tasks`);
} catch (error) {
console.error(`β [Completed Results] Error updating completed modal results:`, error);
}
}
function updateTidalCardPhase(playlistId, phase) {
const state = tidalPlaylistStates[playlistId];
if (!state) return;
state.phase = phase;
// Re-render the card with new phase
const card = document.getElementById(`tidal-card-${playlistId}`);
if (card) {
const oldButtonText = card.querySelector('.playlist-card-action-btn')?.textContent || 'unknown';
const newCardHtml = createTidalCard(state.playlist);
card.outerHTML = newCardHtml;
// Verify the card was actually updated
const updatedCard = document.getElementById(`tidal-card-${playlistId}`);
const newButtonText = updatedCard?.querySelector('.playlist-card-action-btn')?.textContent || 'unknown';
console.log(`π [Card Update] Re-rendered Tidal card ${playlistId}:`);
console.log(` π Phase: ${phase}`);
console.log(` π Button text: "${oldButtonText}" β "${newButtonText}"`);
console.log(` β
Expected: "${getActionButtonText(phase)}"`);
if (newButtonText !== getActionButtonText(phase)) {
console.error(`β [Card Update] Button text mismatch! Expected "${getActionButtonText(phase)}", got "${newButtonText}"`);
}
// Re-attach click handler
const newCard = document.getElementById(`tidal-card-${playlistId}`);
if (newCard) {
newCard.addEventListener('click', () => handleTidalCardClick(playlistId));
console.debug(`π [Card Update] Reattached click handler for Tidal card: ${playlistId}`);
} else {
console.error(`β [Card Update] Failed to find new card after rendering: tidal-card-${playlistId}`);
}
// If we have sync progress and we're in sync/sync_complete phase, restore it
if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) {
setTimeout(() => {
updateTidalCardSyncProgress(playlistId, state.lastSyncProgress);
}, 0);
}
}
console.log(`π΅ Updated Tidal card phase: ${playlistId} -> ${phase}`);
}
async function openTidalDiscoveryModal(playlistId, playlistData) {
console.log(`π΅ Opening Tidal discovery modal (reusing YouTube modal): ${playlistData.name}`);
// Create a fake YouTube-style urlHash for the modal system
const fakeUrlHash = `tidal_${playlistId}`;
// Get current Tidal card state to check if discovery is already done or in progress
const tidalCardState = tidalPlaylistStates[playlistId];
const isAlreadyDiscovered = tidalCardState && (tidalCardState.phase === 'discovered' || tidalCardState.phase === 'syncing' || tidalCardState.phase === 'sync_complete');
const isCurrentlyDiscovering = tidalCardState && tidalCardState.phase === 'discovering';
// Prepare discovery results in the correct format for modal
let transformedResults = [];
let actualMatches = 0;
if (isAlreadyDiscovered && tidalCardState.discovery_results) {
transformedResults = tidalCardState.discovery_results.map((result, index) => {
// Check multiple status formats
const isFound = result.status === 'found' ||
result.status === 'β
Found' ||
result.status_class === 'found' ||
result.spotify_data ||
result.spotify_track;
if (isFound) actualMatches++;
return {
index: index,
yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown',
yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? 'β
Found' : 'β Not Found',
status_class: isFound ? 'found' : '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)
? result.spotify_data.artists
.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a)
.filter(Boolean)
.join(', ') || '-'
: 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, // Pass through spotify_data
spotify_id: result.spotify_id, // Pass through spotify_id
manual_match: result.manual_match // Pass through manual match flag
};
});
console.log(`π΅ Tidal modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`);
}
// Create YouTube-compatible state structure
const modalPhase = tidalCardState ? tidalCardState.phase : 'fresh';
youtubePlaylistStates[fakeUrlHash] = {
phase: modalPhase,
playlist: {
name: playlistData.name,
tracks: playlistData.tracks
},
is_tidal_playlist: true, // Flag to identify this as Tidal
tidal_playlist_id: playlistId,
discovery_progress: isAlreadyDiscovered ? 100 : 0,
spotify_matches: isAlreadyDiscovered ? actualMatches : 0, // Backend format (snake_case)
spotifyMatches: isAlreadyDiscovered ? actualMatches : 0, // Frontend format (camelCase) - for button logic
spotify_total: playlistData.tracks.length,
discovery_results: transformedResults,
discoveryResults: transformedResults, // Both formats for compatibility
discoveryProgress: isAlreadyDiscovered ? 100 : 0 // Frontend format for modal progress display
};
// Only start discovery if not already discovered AND not currently discovering
if (!isAlreadyDiscovered && !isCurrentlyDiscovering) {
// Start Tidal discovery process automatically (like sync.py)
try {
console.log(`π Starting Tidal discovery for: ${playlistData.name}`);
const response = await fetch(`/api/tidal/discovery/start/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
console.error('β Error starting Tidal discovery:', result.error);
showToast(`Error starting discovery: ${result.error}`, 'error');
return;
}
console.log('β
Tidal discovery started, beginning polling...');
// Update phase to discovering now that backend discovery is actually started
tidalPlaylistStates[playlistId].phase = 'discovering';
updateTidalCardPhase(playlistId, 'discovering');
// Update modal phase to match
youtubePlaylistStates[fakeUrlHash].phase = 'discovering';
// Start polling for progress
startTidalDiscoveryPolling(fakeUrlHash, playlistId);
} catch (error) {
console.error('β Error starting Tidal discovery:', error);
showToast(`Error starting discovery: ${error.message}`, 'error');
}
} else if (isCurrentlyDiscovering) {
// Resume polling if discovery is already in progress (like YouTube)
console.log(`π Resuming Tidal discovery polling for: ${playlistData.name}`);
startTidalDiscoveryPolling(fakeUrlHash, playlistId);
} else if (tidalCardState && tidalCardState.phase === 'syncing') {
// Resume sync polling if sync is in progress
console.log(`π Resuming Tidal sync polling for: ${playlistData.name}`);
startTidalSyncPolling(fakeUrlHash);
} else {
console.log('β
Using existing results - no need to re-discover');
}
// Reuse YouTube discovery modal (exact sync.py pattern)
openYouTubeDiscoveryModal(fakeUrlHash);
}
function startTidalDiscoveryPolling(fakeUrlHash, playlistId) {
console.log(`π Starting Tidal discovery polling for: ${playlistId}`);
// Stop any existing polling
if (activeYouTubePollers[fakeUrlHash]) {
clearInterval(activeYouTubePollers[fakeUrlHash]);
}
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('discovery:subscribe', { ids: [playlistId] });
_discoveryProgressCallbacks[playlistId] = (data) => {
if (data.error) {
if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; }
socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId];
return;
}
// Transform to YouTube modal format
const transformed = {
progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total,
complete: data.complete,
results: (data.results || []).map((r, i) => {
const isWingIt = r.wing_it_fallback || r.status_class === 'wing-it';
const isFound = !isWingIt && (r.status === 'found' || r.status === 'β
Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track);
return {
index: i, yt_track: r.tidal_track ? r.tidal_track.name : 'Unknown',
yt_artist: r.tidal_track ? (r.tidal_track.artists ? r.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isWingIt ? 'π― Wing It' : (isFound ? 'β
Found' : 'β Not Found'),
status_class: isWingIt ? 'wing-it' : (isFound ? 'found' : 'not-found'),
spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'),
spotify_artist: r.spotify_data && r.spotify_data.artists
? (Array.isArray(r.spotify_data.artists)
? (r.spotify_data.artists
.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a)
.filter(Boolean)
.join(', ') || '-')
: r.spotify_data.artists)
: (r.spotify_artist || '-'),
spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'),
spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match,
wing_it_fallback: isWingIt
};
})
};
const st = youtubePlaylistStates[fakeUrlHash];
if (st) {
st.discovery_progress = data.progress; st.discoveryProgress = data.progress;
st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches;
st.discovery_results = data.results; st.discoveryResults = transformed.results;
st.phase = data.phase;
updateYouTubeDiscoveryModal(fakeUrlHash, transformed);
}
if (tidalPlaylistStates[playlistId]) {
tidalPlaylistStates[playlistId].phase = data.phase;
tidalPlaylistStates[playlistId].discovery_results = data.results;
tidalPlaylistStates[playlistId].spotify_matches = data.spotify_matches;
tidalPlaylistStates[playlistId].discovery_progress = data.progress;
updateTidalCardPhase(playlistId, data.phase);
}
updateTidalCardProgress(playlistId, data);
if (data.complete) {
if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; }
socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId];
}
};
}
const pollInterval = setInterval(async () => {
// Always poll β no dedicated WebSocket events for discovery progress
try {
const response = await fetch(`/api/tidal/discovery/status/${playlistId}`);
const status = await response.json();
if (status.error) {
console.error('β Error polling Tidal discovery status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
return;
}
// Transform Tidal results to YouTube modal format first
const transformedStatus = {
progress: status.progress,
spotify_matches: status.spotify_matches,
spotify_total: status.spotify_total,
complete: status.complete,
results: status.results.map((result, index) => {
const isFound = result.status === 'found' ||
result.status === 'β
Found' ||
result.status_class === 'found' ||
result.spotify_data ||
result.spotify_track;
return {
index: index,
yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown',
yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? 'β
Found' : 'β Not Found',
status_class: isFound ? 'found' : '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)
? (result.spotify_data.artists
.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a)
.filter(Boolean)
.join(', ') || '-')
: 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, // Pass through
spotify_id: result.spotify_id, // Pass through
manual_match: result.manual_match // Pass through
};
})
};
// Update fake YouTube state with Tidal discovery results
const state = youtubePlaylistStates[fakeUrlHash];
if (state) {
state.discovery_progress = status.progress; // Backend format
state.discoveryProgress = status.progress; // Frontend format - for modal progress display
state.spotify_matches = status.spotify_matches; // Backend format
state.spotifyMatches = status.spotify_matches; // Frontend format - for button logic
state.discovery_results = status.results; // Backend format
state.discoveryResults = transformedStatus.results; // Frontend format - for button logic
state.phase = status.phase;
// Update modal with transformed data (reuse YouTube modal update logic)
updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus);
// Update Tidal card phase and save discovery results FIRST
if (tidalPlaylistStates[playlistId]) {
tidalPlaylistStates[playlistId].phase = status.phase;
tidalPlaylistStates[playlistId].discovery_results = status.results;
tidalPlaylistStates[playlistId].spotify_matches = status.spotify_matches;
tidalPlaylistStates[playlistId].discovery_progress = status.progress;
updateTidalCardPhase(playlistId, status.phase);
}
// Update Tidal card progress AFTER phase update to avoid being overwritten
updateTidalCardProgress(playlistId, status);
console.log(`π Tidal discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`);
}
// Stop polling when complete
if (status.complete) {
console.log(`β
Tidal discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
}
} catch (error) {
console.error('β Error polling Tidal discovery:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
}
}, 1000); // Poll every second like YouTube
// Store poller reference (reuse YouTube poller storage)
activeYouTubePollers[fakeUrlHash] = pollInterval;
}
async function loadTidalPlaylistStatesFromBackend() {
// Load all stored Tidal playlist discovery states from backend (similar to YouTube hydration)
try {
console.log('π΅ Loading Tidal playlist states from backend...');
const response = await fetch('/api/tidal/playlists/states');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch Tidal playlist states');
}
const data = await response.json();
const states = data.states || [];
console.log(`π΅ Found ${states.length} stored Tidal playlist states in backend`);
if (states.length === 0) {
console.log('π΅ No Tidal playlist states to hydrate');
return;
}
// Apply states to existing playlist cards
for (const stateInfo of states) {
await applyTidalPlaylistState(stateInfo);
}
// Rehydrate download modals for Tidal playlists in downloading/download_complete phases
for (const stateInfo of states) {
if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') &&
stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) {
const convertedPlaylistId = stateInfo.converted_spotify_playlist_id;
if (!activeDownloadProcesses[convertedPlaylistId]) {
console.log(`π§ Rehydrating download modal for Tidal playlist: ${stateInfo.playlist_id}`);
try {
// Get the playlist data
const playlistData = tidalPlaylists.find(p => p.id === stateInfo.playlist_id);
if (!playlistData) {
console.warn(`β οΈ Playlist data not found for rehydration: ${stateInfo.playlist_id}`);
continue;
}
// Create the download modal using the Tidal-specific function
const spotifyTracks = tidalPlaylistStates[stateInfo.playlist_id]?.discovery_results
?.filter(result => result.spotify_data)
?.map(result => result.spotify_data) || [];
if (spotifyTracks.length > 0) {
await openDownloadMissingModalForTidal(
convertedPlaylistId,
playlistData.name,
spotifyTracks
);
// Set the modal to running state with the correct batch ID
const process = activeDownloadProcesses[convertedPlaylistId];
if (process) {
process.status = 'running';
process.batchId = stateInfo.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);
console.log(`β
Rehydrated Tidal download modal for batch ${stateInfo.download_process_id}`);
}
} else {
console.warn(`β οΈ No Spotify tracks found for Tidal playlist rehydration: ${stateInfo.playlist_id}`);
}
} catch (error) {
console.error(`β Error rehydrating Tidal download modal for ${stateInfo.playlist_id}:`, error);
}
}
}
}
console.log('β
Tidal playlist states loaded and applied');
} catch (error) {
console.error('β Error loading Tidal playlist states:', error);
}
}
async function applyTidalPlaylistState(stateInfo) {
const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo;
try {
console.log(`π΅ Applying saved state for Tidal playlist: ${playlist_id}, Phase: ${phase}`);
// Find the playlist data from the loaded playlists
const playlistData = tidalPlaylists.find(p => p.id === playlist_id);
if (!playlistData) {
console.warn(`β οΈ Playlist data not found for state ${playlist_id} - skipping`);
return;
}
// Update local state
if (!tidalPlaylistStates[playlist_id]) {
// Initialize state if it doesn't exist
tidalPlaylistStates[playlist_id] = {
playlist: playlistData,
phase: 'fresh'
};
}
// Update with backend state
tidalPlaylistStates[playlist_id].phase = phase;
tidalPlaylistStates[playlist_id].discovery_progress = discovery_progress;
tidalPlaylistStates[playlist_id].spotify_matches = spotify_matches;
tidalPlaylistStates[playlist_id].discovery_results = discovery_results;
tidalPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id;
tidalPlaylistStates[playlist_id].download_process_id = download_process_id;
tidalPlaylistStates[playlist_id].playlist = playlistData; // Ensure playlist data is set
// Fetch full discovery results for non-fresh playlists (matching YouTube pattern)
if (phase !== 'fresh' && phase !== 'discovering') {
try {
console.log(`π Fetching full discovery results for Tidal playlist: ${playlistData.name}`);
const stateResponse = await fetch(`/api/tidal/state/${playlist_id}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
console.log(`π Retrieved full Tidal state with ${fullState.discovery_results?.length || 0} discovery results`);
// Store full discovery results in local state (matching YouTube pattern)
if (fullState.discovery_results && tidalPlaylistStates[playlist_id]) {
tidalPlaylistStates[playlist_id].discovery_results = fullState.discovery_results;
tidalPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress;
tidalPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches;
tidalPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
tidalPlaylistStates[playlist_id].download_process_id = fullState.download_process_id;
console.log(`β
Restored ${fullState.discovery_results.length} discovery results for Tidal playlist: ${playlistData.name}`);
}
} else {
console.warn(`β οΈ Could not fetch full discovery results for Tidal playlist: ${playlistData.name}`);
}
} catch (error) {
console.warn(`β οΈ Error fetching full discovery results for Tidal playlist ${playlistData.name}:`, error.message);
}
}
// Update the card UI to reflect the saved state
updateTidalCardPhase(playlist_id, phase);
// Update card progress if we have discovery results
if (phase === 'discovered' && tidalPlaylistStates[playlist_id]) {
const progressInfo = {
spotify_total: playlistData.track_count || playlistData.tracks?.length || 0,
spotify_matches: tidalPlaylistStates[playlist_id].spotify_matches || 0
};
updateTidalCardProgress(playlist_id, progressInfo);
}
// Handle active polling resumption (matching YouTube/Beatport pattern)
if (phase === 'discovering') {
console.log(`π Resuming discovery polling for Tidal: ${playlistData.name}`);
const fakeUrlHash = `tidal_${playlist_id}`;
startTidalDiscoveryPolling(fakeUrlHash, playlist_id);
} else if (phase === 'syncing') {
console.log(`π Resuming sync polling for Tidal: ${playlistData.name}`);
const fakeUrlHash = `tidal_${playlist_id}`;
startTidalSyncPolling(fakeUrlHash);
}
console.log(`β
Applied saved state for Tidal playlist: ${playlist_id} -> ${phase}`);
} catch (error) {
console.error(`β Error applying Tidal playlist state for ${playlist_id}:`, error);
}
}
function updateTidalCardProgress(playlistId, progress) {
const state = tidalPlaylistStates[playlistId];
if (!state) return;
const card = document.getElementById(`tidal-card-${playlistId}`);
if (!card) return;
const progressElement = card.querySelector('.playlist-card-progress');
if (!progressElement) return;
const total = progress.spotify_total || 0;
const matches = progress.spotify_matches || 0;
const failed = total - matches;
const percentage = total > 0 ? Math.round((matches / total) * 100) : 0;
progressElement.textContent = `βͺ ${total} / β ${matches} / β ${failed} / ${percentage}%`;
progressElement.classList.remove('hidden'); // Show progress during discovery
console.log('π΅ Updated Tidal card progress:', playlistId, `${matches}/${total} (${percentage}%)`);
}
// ===============================
// TIDAL SYNC FUNCTIONALITY
// ===============================
async function startTidalPlaylistSync(urlHash) {
try {
console.log('π΅ Starting Tidal playlist sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_tidal_playlist) {
console.error('β Invalid Tidal playlist state for sync');
return;
}
const playlistId = state.tidal_playlist_id;
const response = await fetch(`/api/tidal/sync/start/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error starting sync: ${result.error}`, 'error');
return;
}
// Capture sync_playlist_id for WebSocket subscription
const syncPlaylistId = result.sync_playlist_id;
if (state) state.syncPlaylistId = syncPlaylistId;
// Update card and modal to syncing phase
updateTidalCardPhase(playlistId, 'syncing');
// Update modal buttons if modal is open
updateTidalModalButtons(urlHash, 'syncing');
// Start sync polling
startTidalSyncPolling(urlHash, syncPlaylistId);
showToast('Tidal playlist sync started!', 'success');
} catch (error) {
console.error('β Error starting Tidal sync:', error);
showToast(`Error starting sync: ${error.message}`, 'error');
}
}
function startTidalSyncPolling(urlHash, syncPlaylistId) {
// Stop any existing polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
const state = youtubePlaylistStates[urlHash];
const playlistId = state.tidal_playlist_id;
// Resolve syncPlaylistId from argument or stored state
syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId);
// Phase 6: Subscribe via WebSocket
if (socketConnected && syncPlaylistId) {
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
_syncProgressCallbacks[syncPlaylistId] = (data) => {
const progress = data.progress || {};
updateTidalCardSyncProgress(playlistId, progress);
updateTidalModalSyncProgress(urlHash, progress);
if (data.status === 'finished') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'sync_complete';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
updateTidalCardPhase(playlistId, 'sync_complete');
updateTidalModalButtons(urlHash, 'sync_complete');
showToast('Tidal playlist sync complete!', 'success');
} else if (data.status === 'error' || data.status === 'cancelled') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'discovered';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
updateTidalCardPhase(playlistId, 'discovered');
updateTidalModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
}
};
}
// Define the polling function (HTTP fallback)
const pollFunction = async () => {
if (socketConnected) return; // Phase 6: WS handles updates
try {
const response = await fetch(`/api/tidal/sync/status/${playlistId}`);
const status = await response.json();
if (status.error) {
console.error('β Error polling Tidal sync status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
updateTidalCardSyncProgress(playlistId, status.progress);
updateTidalModalSyncProgress(urlHash, status.progress);
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'sync_complete';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
updateTidalCardPhase(playlistId, 'sync_complete');
updateTidalModalButtons(urlHash, 'sync_complete');
showToast('Tidal playlist sync complete!', 'success');
} else if (status.sync_status === 'error') {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'discovered';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
updateTidalCardPhase(playlistId, 'discovered');
updateTidalModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('β Error polling Tidal sync:', error);
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
}
};
// Run immediately to get current status (skip if WS active)
if (!socketConnected) pollFunction();
// Then continue polling at regular intervals
const pollInterval = setInterval(pollFunction, 1000);
activeYouTubePollers[urlHash] = pollInterval;
}
async function cancelTidalSync(urlHash) {
try {
console.log('β Cancelling Tidal sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_tidal_playlist) {
console.error('β Invalid Tidal playlist state');
return;
}
const playlistId = state.tidal_playlist_id;
const response = await fetch(`/api/tidal/sync/cancel/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error cancelling sync: ${result.error}`, 'error');
return;
}
// Stop polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Phase 6: Clean up WS subscription
const syncId = state && state.syncPlaylistId;
if (syncId && _syncProgressCallbacks[syncId]) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] });
delete _syncProgressCallbacks[syncId];
}
// Revert to discovered phase
updateTidalCardPhase(playlistId, 'discovered');
updateTidalModalButtons(urlHash, 'discovered');
showToast('Tidal sync cancelled', 'info');
} catch (error) {
console.error('β Error cancelling Tidal sync:', error);
showToast(`Error cancelling sync: ${error.message}`, 'error');
}
}
function updateTidalCardSyncProgress(playlistId, progress) {
const state = tidalPlaylistStates[playlistId];
if (!state || !state.playlist || !progress) return;
// Save the progress for later restoration
state.lastSyncProgress = progress;
const card = document.getElementById(`tidal-card-${playlistId}`);
if (!card) return;
const progressElement = card.querySelector('.playlist-card-progress');
// Build clean status counter HTML exactly like YouTube cards
let statusCounterHTML = '';
if (progress && progress.total_tracks > 0) {
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const processed = matched + failed;
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
statusCounterHTML = `
βͺ ${total}
/
β ${matched}
/
β ${failed}
(${percentage}%)
`;
}
// Only update if we have valid sync progress, otherwise preserve existing discovery results
if (statusCounterHTML) {
progressElement.innerHTML = statusCounterHTML;
}
console.log(`π΅ Updated Tidal card sync progress: βͺ ${progress?.total_tracks || 0} / β ${progress?.matched_tracks || 0} / β ${progress?.failed_tracks || 0}`);
}
function updateTidalModalSyncProgress(urlHash, progress) {
const statusDisplay = document.getElementById(`tidal-sync-status-${urlHash}`);
if (!statusDisplay || !progress) return;
console.log(`π Updating Tidal modal sync progress for ${urlHash}:`, progress);
// Update individual counters exactly like YouTube sync
const totalEl = document.getElementById(`tidal-total-${urlHash}`);
const matchedEl = document.getElementById(`tidal-matched-${urlHash}`);
const failedEl = document.getElementById(`tidal-failed-${urlHash}`);
const percentageEl = document.getElementById(`tidal-percentage-${urlHash}`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
// Calculate percentage like YouTube sync
if (total > 0) {
const processed = matched + failed;
const percentage = Math.round((processed / total) * 100);
if (percentageEl) percentageEl.textContent = percentage;
}
console.log(`π Tidal modal updated: βͺ ${total} / β ${matched} / β ${failed} (${Math.round((matched + failed) / total * 100)}%)`);
}
function updateTidalModalButtons(urlHash, phase) {
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (!modal) return;
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
}
}
async function startTidalDownloadMissing(urlHash) {
try {
console.log('π Starting download missing tracks for Tidal playlist:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_tidal_playlist) {
console.error('β Invalid Tidal playlist state for download');
return;
}
// Tidal reuses youtubePlaylistStates infrastructure, so get results from there
const discoveryResults = state.discoveryResults || state.discovery_results;
if (!discoveryResults) {
showToast('No discovery results available for download', 'error');
return;
}
// Convert Tidal discovery results to Spotify tracks format (same as YouTube)
const spotifyTracks = [];
for (const result of discoveryResults) {
if (result.spotify_data) {
spotifyTracks.push(result.spotify_data);
} else if (result.spotify_track && result.status_class === 'found') {
// Build from individual fields (automatic discovery format)
// Convert album to proper object format for wishlist compatibility
const albumData = result.spotify_album || 'Unknown Album';
const albumObject = typeof albumData === 'object' && albumData !== null
? albumData
: {
name: typeof albumData === 'string' ? albumData : 'Unknown Album',
album_type: 'album',
images: []
};
spotifyTracks.push({
id: result.spotify_id || 'unknown',
name: result.spotify_track || 'Unknown Track',
artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'],
album: albumObject,
duration_ms: 0
});
}
}
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
// Create a virtual playlist for the download system
const virtualPlaylistId = `tidal_${state.tidal_playlist_id}`;
const playlistName = state.playlist.name;
// Store reference for card navigation (same as YouTube)
state.convertedSpotifyPlaylistId = virtualPlaylistId;
// Close the discovery modal if it's open (same as YouTube)
const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (discoveryModal) {
discoveryModal.classList.add('hidden');
console.log('π Closed Tidal discovery modal to show download modal');
}
// Open download missing tracks modal for Tidal playlist
await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks);
// Phase will change to 'downloading' when user clicks "Begin Analysis" button
} catch (error) {
console.error('β Error starting download missing tracks:', error);
showToast(`Error starting downloads: ${error.message}`, 'error');
}
}
async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks) {
showLoadingOverlay('Loading Tidal playlist...');
// Check if a process is already active for this virtual playlist
if (activeDownloadProcesses[virtualPlaylistId]) {
console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`);
const process = activeDownloadProcesses[virtualPlaylistId];
if (process.modalElement) {
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
}
return;
}
console.log(`π₯ Opening Download Missing Tracks modal for Tidal playlist: ${virtualPlaylistId}`);
// Create virtual playlist object for compatibility with existing modal logic
const virtualPlaylist = {
id: virtualPlaylistId,
name: playlistName,
track_count: spotifyTracks.length
};
// Store the tracks in the cache for the modal to use
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
currentPlaylistTracks = spotifyTracks;
currentModalPlaylistId = virtualPlaylistId;
let modal = document.createElement('div');
modal.id = `download-missing-modal-${virtualPlaylistId}`;
modal.className = 'download-missing-modal';
modal.style.display = 'none';
document.body.appendChild(modal);
// Register the new process in our global state tracker using the same structure as Spotify
activeDownloadProcesses[virtualPlaylistId] = {
status: 'idle',
modalElement: modal,
poller: null,
batchId: null,
playlist: virtualPlaylist,
tracks: spotifyTracks
};
// Generate hero section with dynamic source detection (same as YouTube/Beatport)
const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' :
virtualPlaylistId.startsWith('tidal_') ? 'Tidal' :
virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' :
virtualPlaylistId.startsWith('spotify_public_') ? 'Spotify' :
virtualPlaylistId.startsWith('spotify:') ? 'Spotify' :
virtualPlaylistId.startsWith('discover_') ? 'SoulSync' :
virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' :
virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' :
virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' :
virtualPlaylistId.startsWith('decade_') ? 'SoulSync' :
virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
'YouTube';
const heroContext = {
type: 'playlist',
playlist: { name: playlistName, owner: source },
trackCount: spotifyTracks.length,
playlistId: virtualPlaylistId
};
// Use the exact same modal HTML structure as the existing Spotify modal
modal.innerHTML = `
π Library Analysis
Ready to start
β¬ Downloads
Waiting for analysis
`;
applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length);
modal.style.display = 'flex';
hideLoadingOverlay();
}
// ===================================================================
// DEEZER ARL PLAYLIST MANAGEMENT (Spotify-identical pattern)
// ===================================================================
async function loadDeezerArlPlaylists() {
const container = document.getElementById('deezer-arl-playlist-container');
const refreshBtn = document.getElementById('deezer-arl-refresh-btn');
container.innerHTML = `π Loading playlists...
`;
refreshBtn.disabled = true;
refreshBtn.textContent = 'π Loading...';
try {
const response = await fetch('/api/deezer/arl-playlists');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch Deezer playlists');
}
deezerArlPlaylists = await response.json();
renderDeezerArlPlaylists();
deezerArlPlaylistsLoaded = true;
// Check for active syncs or downloads and rehydrate UI
await checkForActiveProcesses();
for (const p of deezerArlPlaylists) {
const arlId = `deezer_arl_${p.id}`;
try {
const syncResp = await fetch(`/api/sync/status/${arlId}`);
if (syncResp.ok) {
const syncState = await syncResp.json();
if (syncState.status === 'syncing') {
// Re-attach sync polling and update card UI
if (!spotifyPlaylists.find(sp => sp.id === arlId)) {
spotifyPlaylists.push({ id: arlId, name: p.name, track_count: p.track_count || 0, image_url: p.image_url || '', owner: p.owner || '' });
}
updateCardToSyncing(arlId, syncState.progress?.progress || 0, syncState.progress);
startSyncPolling(arlId);
console.log(`π Rehydrated active sync for Deezer ARL playlist: ${p.name}`);
}
}
} catch (e) { /* No active sync β normal */ }
}
} catch (error) {
container.innerHTML = `β Error: ${error.message}
`;
showToast(`Error loading Deezer playlists: ${error.message}`, 'error');
} finally {
refreshBtn.disabled = false;
refreshBtn.textContent = 'π Refresh';
}
}
function renderDeezerArlPlaylists() {
const container = document.getElementById('deezer-arl-playlist-container');
if (deezerArlPlaylists.length === 0) {
container.innerHTML = `No Deezer playlists found.
`;
return;
}
container.innerHTML = deezerArlPlaylists.map(p => {
const arlId = `deezer_arl_${p.id}`;
let statusClass = 'status-never-synced';
if (p.sync_status && p.sync_status.startsWith('Synced')) statusClass = 'status-synced';
return `
${escapeHtml(p.name)}
${p.track_count} tracks β’
${p.sync_status || 'Never Synced'}
Sync / Download
View Progress
`;
}).join('');
}
function handleDeezerArlViewProgressClick(event, playlistId) {
event.stopPropagation();
const arlPlaylistId = `deezer_arl_${playlistId}`;
const process = activeDownloadProcesses[arlPlaylistId];
if (process && process.modalElement) {
process.modalElement.style.display = 'flex';
}
}
async function openDeezerArlPlaylistDetailsModal(event, playlistId) {
event.stopPropagation();
const playlist = deezerArlPlaylists.find(p => String(p.id) === String(playlistId));
if (!playlist) return;
const arlPlaylistId = `deezer_arl_${playlistId}`;
showLoadingOverlay(`Loading playlist: ${playlist.name}...`);
try {
if (playlistTrackCache[arlPlaylistId]) {
const fullPlaylist = { ...playlist, id: arlPlaylistId, tracks: playlistTrackCache[arlPlaylistId] };
showDeezerArlPlaylistDetailsModal(fullPlaylist, playlistId);
} else {
const response = await fetch(`/api/deezer/arl-playlist/${playlistId}`);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
playlistTrackCache[arlPlaylistId] = fullPlaylist.tracks;
// Auto-mirror
mirrorPlaylist('deezer', 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,
source_track_id: t.id || ''
})), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url });
showDeezerArlPlaylistDetailsModal({ ...fullPlaylist, id: arlPlaylistId }, playlistId);
}
} catch (error) {
showToast(`Error: ${error.message}`, 'error');
} finally {
hideLoadingOverlay();
}
}
function showDeezerArlPlaylistDetailsModal(playlist, originalDeezerPlaylistId) {
let modal = document.getElementById('deezer-arl-playlist-details-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'deezer-arl-playlist-details-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
}
const playlistId = playlist.id;
const activeProcess = activeDownloadProcesses[playlistId];
const hasCompletedProcess = activeProcess && activeProcess.status === 'complete';
const isSyncing = !!activeSyncPollers[playlistId];
modal.innerHTML = `
${playlist.description ? `
${escapeHtml(playlist.description)}
` : ''}
${(playlist.tracks || []).map((track, index) => `
${index + 1}
${escapeHtml(track.name)}
${formatArtists(track.artists)}
${formatDuration(track.duration_ms)}
`).join('')}
`;
// Store playlist in spotifyPlaylists-compatible format for openDownloadMissingModal
if (!spotifyPlaylists.find(p => p.id === playlistId)) {
spotifyPlaylists.push({
id: playlistId,
name: playlist.name,
track_count: playlist.tracks ? playlist.tracks.length : 0,
image_url: playlist.image_url || '',
owner: playlist.owner || '',
});
}
modal.style.display = 'flex';
}
function closeDeezerArlPlaylistDetailsModal() {
const modal = document.getElementById('deezer-arl-playlist-details-modal');
if (modal) modal.style.display = 'none';
}
function updateDeezerArlPlaylistCardUI(playlistId) {
const arlPlaylistId = `deezer_arl_${playlistId}`;
const process = activeDownloadProcesses[arlPlaylistId];
const progressBtn = document.getElementById(`progress-btn-${arlPlaylistId}`);
const actionBtn = document.getElementById(`action-btn-${arlPlaylistId}`);
const card = document.querySelector(`.playlist-card[data-playlist-id="${arlPlaylistId}"]`);
if (!progressBtn || !actionBtn) return;
if (process && process.status === 'running') {
progressBtn.classList.remove('hidden');
progressBtn.textContent = 'View Progress';
progressBtn.style.backgroundColor = '';
actionBtn.textContent = 'π₯ Downloading...';
actionBtn.disabled = true;
if (card) card.classList.remove('download-complete');
} else if (process && process.status === 'complete') {
progressBtn.classList.remove('hidden');
progressBtn.textContent = 'π View Results';
progressBtn.style.backgroundColor = '#28a745';
progressBtn.style.color = 'white';
actionBtn.textContent = 'β
Ready for Review';
actionBtn.disabled = false;
if (card) card.classList.add('download-complete');
} else {
progressBtn.classList.add('hidden');
progressBtn.style.backgroundColor = '';
progressBtn.style.color = '';
actionBtn.textContent = 'Sync / Download';
actionBtn.disabled = false;
if (card) card.classList.remove('download-complete');
}
}
// ===================================================================
// DEEZER PLAYLIST MANAGEMENT (URL-input like YouTube, reuses YouTube modal)
// ===================================================================
async function loadDeezerPlaylist() {
const urlInput = document.getElementById('deezer-url-input');
if (!urlInput) return;
const rawUrl = urlInput.value.trim();
if (!rawUrl) {
showToast('Please paste a Deezer playlist URL', 'error');
return;
}
// Extract playlist ID from URL
// Supports: deezer.com/playlist/{id}, deezer.com/{locale}/playlist/{id}, or raw numeric ID
let playlistId = null;
const urlMatch = rawUrl.match(/deezer\.com\/(?:[a-z]{2}\/)?playlist\/(\d+)/i);
if (urlMatch) {
playlistId = urlMatch[1];
} else if (/^\d+$/.test(rawUrl)) {
playlistId = rawUrl;
}
if (!playlistId) {
showToast('Invalid Deezer playlist URL. Expected format: deezer.com/playlist/{id}', 'error');
return;
}
// Check if already loaded
if (deezerPlaylists.find(p => String(p.id) === String(playlistId))) {
showToast('This playlist is already loaded', 'info');
urlInput.value = '';
return;
}
const parseBtn = document.getElementById('deezer-parse-btn');
if (parseBtn) {
parseBtn.disabled = true;
parseBtn.textContent = 'Loading...';
}
try {
const response = await fetch(`/api/deezer/playlist/${playlistId}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch Deezer playlist');
}
const playlist = await response.json();
deezerPlaylists.push(playlist);
// Auto-mirror Deezer playlist
if (playlist.tracks && playlist.tracks.length > 0) {
mirrorPlaylist('deezer', playlist.id, playlist.name, playlist.tracks.map(t => ({
track_name: t.name || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artists || ''),
album_name: typeof t.album === 'string' ? t.album : '', duration_ms: t.duration_ms || 0,
source_track_id: t.id || ''
})), { owner: playlist.owner, image_url: playlist.image_url, description: rawUrl });
}
// Save to URL history
saveUrlHistory('deezer', rawUrl, playlist.name);
renderDeezerPlaylists();
await loadDeezerPlaylistStatesFromBackend();
urlInput.value = '';
showToast(`Deezer playlist loaded: ${playlist.name} (${playlist.track_count || playlist.tracks.length} tracks)`, 'success');
console.log(`π΅ Loaded Deezer playlist: ${playlist.name}`);
} catch (error) {
showToast(`Error loading Deezer playlist: ${error.message}`, 'error');
} finally {
if (parseBtn) {
parseBtn.disabled = false;
parseBtn.textContent = 'Load Playlist';
}
}
}
function renderDeezerPlaylists() {
const container = document.getElementById('deezer-playlist-container');
if (deezerPlaylists.length === 0) {
container.innerHTML = `Paste a Deezer playlist URL above to get started.
`;
return;
}
container.innerHTML = deezerPlaylists.map(p => {
if (!deezerPlaylistStates[p.id]) {
deezerPlaylistStates[p.id] = {
phase: 'fresh',
playlist: p
};
}
return createDeezerCard(p);
}).join('');
// Add click handlers to cards
deezerPlaylists.forEach(p => {
const card = document.getElementById(`deezer-card-${p.id}`);
if (card) {
card.addEventListener('click', () => handleDeezerCardClick(p.id));
}
});
}
function createDeezerCard(playlist) {
const state = deezerPlaylistStates[playlist.id];
const phase = state.phase;
let buttonText = getActionButtonText(phase);
let phaseText = getPhaseText(phase);
let phaseColor = getPhaseColor(phase);
return `
π΅
${escapeHtml(playlist.name)}
${playlist.track_count || playlist.tracks.length} tracks
${phaseText}
${buttonText}
`;
}
async function handleDeezerCardClick(playlistId) {
const state = deezerPlaylistStates[playlistId];
if (!state) {
console.error(`No state found for Deezer playlist: ${playlistId}`);
showToast('Playlist state not found - try refreshing the page', 'error');
return;
}
if (!state.playlist) {
console.error(`No playlist data found for Deezer playlist: ${playlistId}`);
showToast('Playlist data missing - try refreshing the page', 'error');
return;
}
if (!state.phase) {
state.phase = 'fresh';
}
console.log(`π΅ [Card Click] Deezer card clicked: ${playlistId}, Phase: ${state.phase}`);
if (state.phase === 'fresh') {
console.log(`π΅ Using pre-loaded Deezer playlist data for: ${state.playlist.name}`);
openDeezerDiscoveryModal(playlistId, state.playlist);
} else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') {
console.log(`π΅ [Card Click] Opening Deezer discovery modal for ${state.phase} phase`);
if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) {
try {
const stateResponse = await fetch(`/api/deezer/state/${playlistId}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
if (fullState.discovery_results) {
state.discovery_results = fullState.discovery_results;
state.spotify_matches = fullState.spotify_matches || state.spotify_matches;
state.discovery_progress = fullState.discovery_progress || state.discovery_progress;
deezerPlaylistStates[playlistId] = { ...deezerPlaylistStates[playlistId], ...state };
console.log(`Restored ${fullState.discovery_results.length} discovery results from backend`);
}
}
} catch (error) {
console.error(`Failed to fetch discovery results from backend: ${error}`);
}
}
openDeezerDiscoveryModal(playlistId, state.playlist);
} else if (state.phase === 'downloading' || state.phase === 'download_complete') {
if (state.convertedSpotifyPlaylistId) {
if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) {
const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId];
if (process.modalElement) {
process.modalElement.style.display = 'flex';
} else {
await rehydrateDeezerDownloadModal(playlistId, state);
}
} else {
await rehydrateDeezerDownloadModal(playlistId, state);
}
} else {
if (state.discovery_results && state.discovery_results.length > 0) {
openDeezerDiscoveryModal(playlistId, state.playlist);
} else {
showToast('Unable to open download modal - missing playlist data', 'error');
}
}
}
}
async function rehydrateDeezerDownloadModal(playlistId, state) {
try {
if (!state || !state.playlist) {
showToast('Cannot open download modal - invalid playlist data', 'error');
return;
}
const spotifyTracks = state.discovery_results
?.filter(result => result.spotify_data)
?.map(result => result.spotify_data) || [];
if (spotifyTracks.length > 0) {
const virtualPlaylistId = state.convertedSpotifyPlaylistId || `deezer_${playlistId}`;
await openDownloadMissingModalForTidal(virtualPlaylistId, state.playlist.name, spotifyTracks);
if (state.download_process_id) {
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = 'running';
process.batchId = state.download_process_id;
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';
startModalDownloadPolling(virtualPlaylistId);
}
}
} else {
showToast('No Spotify tracks found for download', 'error');
}
} catch (error) {
console.error(`Error rehydrating Deezer download modal: ${error}`);
}
}
async function openDeezerDiscoveryModal(playlistId, playlistData) {
console.log(`π΅ Opening Deezer discovery modal (reusing YouTube modal): ${playlistData.name}`);
const fakeUrlHash = `deezer_${playlistId}`;
const deezerCardState = deezerPlaylistStates[playlistId];
const isAlreadyDiscovered = deezerCardState && (deezerCardState.phase === 'discovered' || deezerCardState.phase === 'syncing' || deezerCardState.phase === 'sync_complete');
const isCurrentlyDiscovering = deezerCardState && deezerCardState.phase === 'discovering';
let transformedResults = [];
let actualMatches = 0;
if (isAlreadyDiscovered && deezerCardState.discovery_results) {
transformedResults = deezerCardState.discovery_results.map((result, index) => {
const isFound = result.status === 'found' ||
result.status === 'β
Found' ||
result.status_class === 'found' ||
result.spotify_data ||
result.spotify_track;
if (isFound) actualMatches++;
return {
index: index,
yt_track: result.deezer_track ? result.deezer_track.name : 'Unknown',
yt_artist: result.deezer_track ? (result.deezer_track.artists ? result.deezer_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? 'β
Found' : 'β Not Found',
status_class: isFound ? 'found' : '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)
? result.spotify_data.artists
.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a)
.filter(Boolean)
.join(', ') || '-'
: 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,
spotify_id: result.spotify_id,
manual_match: result.manual_match
};
});
console.log(`π΅ Deezer modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`);
}
const modalPhase = deezerCardState ? deezerCardState.phase : 'fresh';
youtubePlaylistStates[fakeUrlHash] = {
phase: modalPhase,
playlist: {
name: playlistData.name,
tracks: playlistData.tracks
},
is_deezer_playlist: true,
deezer_playlist_id: playlistId,
discovery_progress: isAlreadyDiscovered ? 100 : 0,
spotify_matches: isAlreadyDiscovered ? actualMatches : 0,
spotifyMatches: isAlreadyDiscovered ? actualMatches : 0,
spotify_total: playlistData.tracks.length,
discovery_results: transformedResults,
discoveryResults: transformedResults,
discoveryProgress: isAlreadyDiscovered ? 100 : 0
};
if (!isAlreadyDiscovered && !isCurrentlyDiscovering) {
try {
console.log(`π Starting Deezer discovery for: ${playlistData.name}`);
const response = await fetch(`/api/deezer/discovery/start/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
console.error('Error starting Deezer discovery:', result.error);
showToast(`Error starting discovery: ${result.error}`, 'error');
return;
}
console.log('Deezer discovery started, beginning polling...');
deezerPlaylistStates[playlistId].phase = 'discovering';
updateDeezerCardPhase(playlistId, 'discovering');
youtubePlaylistStates[fakeUrlHash].phase = 'discovering';
startDeezerDiscoveryPolling(fakeUrlHash, playlistId);
} catch (error) {
console.error('Error starting Deezer discovery:', error);
showToast(`Error starting discovery: ${error.message}`, 'error');
}
} else if (isCurrentlyDiscovering) {
console.log(`π Resuming Deezer discovery polling for: ${playlistData.name}`);
startDeezerDiscoveryPolling(fakeUrlHash, playlistId);
} else if (deezerCardState && deezerCardState.phase === 'syncing') {
console.log(`π Resuming Deezer sync polling for: ${playlistData.name}`);
startDeezerSyncPolling(fakeUrlHash);
} else {
console.log('Using existing results - no need to re-discover');
}
openYouTubeDiscoveryModal(fakeUrlHash);
}
function startDeezerDiscoveryPolling(fakeUrlHash, playlistId) {
console.log(`π Starting Deezer discovery polling for: ${playlistId}`);
if (activeYouTubePollers[fakeUrlHash]) {
clearInterval(activeYouTubePollers[fakeUrlHash]);
}
// WebSocket subscription
if (socketConnected) {
socket.emit('discovery:subscribe', { ids: [playlistId] });
_discoveryProgressCallbacks[playlistId] = (data) => {
if (data.error) {
if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; }
socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId];
return;
}
const transformed = {
progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total,
complete: data.complete,
results: (data.results || []).map((r, i) => {
const isWingIt = r.wing_it_fallback || r.status_class === 'wing-it';
const isFound = !isWingIt && (r.status === 'found' || r.status === 'β
Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track);
return {
index: i, yt_track: r.deezer_track ? r.deezer_track.name : 'Unknown',
yt_artist: r.deezer_track ? (r.deezer_track.artists ? r.deezer_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isWingIt ? 'π― Wing It' : (isFound ? 'β
Found' : 'β Not Found'),
status_class: isWingIt ? 'wing-it' : (isFound ? 'found' : 'not-found'),
spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'),
spotify_artist: r.spotify_data && r.spotify_data.artists
? (Array.isArray(r.spotify_data.artists)
? (r.spotify_data.artists
.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a)
.filter(Boolean)
.join(', ') || '-')
: r.spotify_data.artists)
: (r.spotify_artist || '-'),
spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'),
spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match,
wing_it_fallback: isWingIt
};
})
};
const st = youtubePlaylistStates[fakeUrlHash];
if (st) {
st.discovery_progress = data.progress; st.discoveryProgress = data.progress;
st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches;
st.discovery_results = data.results; st.discoveryResults = transformed.results;
st.phase = data.phase;
updateYouTubeDiscoveryModal(fakeUrlHash, transformed);
}
if (deezerPlaylistStates[playlistId]) {
deezerPlaylistStates[playlistId].phase = data.phase;
deezerPlaylistStates[playlistId].discovery_results = data.results;
deezerPlaylistStates[playlistId].spotify_matches = data.spotify_matches;
deezerPlaylistStates[playlistId].discovery_progress = data.progress;
updateDeezerCardPhase(playlistId, data.phase);
}
updateDeezerCardProgress(playlistId, data);
if (data.complete) {
if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; }
socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId];
}
};
}
const pollInterval = setInterval(async () => {
if (socketConnected) return;
try {
const response = await fetch(`/api/deezer/discovery/status/${playlistId}`);
const status = await response.json();
if (status.error) {
console.error('Error polling Deezer discovery status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
return;
}
const transformedStatus = {
progress: status.progress,
spotify_matches: status.spotify_matches,
spotify_total: status.spotify_total,
complete: status.complete,
results: status.results.map((result, index) => {
const isFound = result.status === 'found' ||
result.status === 'β
Found' ||
result.status_class === 'found' ||
result.spotify_data ||
result.spotify_track;
return {
index: index,
yt_track: result.deezer_track ? result.deezer_track.name : 'Unknown',
yt_artist: result.deezer_track ? (result.deezer_track.artists ? result.deezer_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? 'β
Found' : 'β Not Found',
status_class: isFound ? 'found' : '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)
? (result.spotify_data.artists
.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a)
.filter(Boolean)
.join(', ') || '-')
: 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,
spotify_id: result.spotify_id,
manual_match: result.manual_match
};
})
};
const state = youtubePlaylistStates[fakeUrlHash];
if (state) {
state.discovery_progress = status.progress;
state.discoveryProgress = status.progress;
state.spotify_matches = status.spotify_matches;
state.spotifyMatches = status.spotify_matches;
state.discovery_results = status.results;
state.discoveryResults = transformedStatus.results;
state.phase = status.phase;
updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus);
if (deezerPlaylistStates[playlistId]) {
deezerPlaylistStates[playlistId].phase = status.phase;
deezerPlaylistStates[playlistId].discovery_results = status.results;
deezerPlaylistStates[playlistId].spotify_matches = status.spotify_matches;
deezerPlaylistStates[playlistId].discovery_progress = status.progress;
updateDeezerCardPhase(playlistId, status.phase);
}
updateDeezerCardProgress(playlistId, status);
console.log(`π Deezer discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`);
}
if (status.complete) {
console.log(`Deezer discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
}
} catch (error) {
console.error('Error polling Deezer discovery:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
}
}, 1000);
activeYouTubePollers[fakeUrlHash] = pollInterval;
}
async function loadDeezerPlaylistStatesFromBackend() {
try {
console.log('π΅ Loading Deezer playlist states from backend...');
const response = await fetch('/api/deezer/playlists/states');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch Deezer playlist states');
}
const data = await response.json();
const states = data.states || [];
console.log(`π΅ Found ${states.length} stored Deezer playlist states in backend`);
if (states.length === 0) return;
for (const stateInfo of states) {
await applyDeezerPlaylistState(stateInfo);
}
// Rehydrate download modals for Deezer playlists in downloading/download_complete phases
for (const stateInfo of states) {
if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') &&
stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) {
const convertedPlaylistId = stateInfo.converted_spotify_playlist_id;
if (!activeDownloadProcesses[convertedPlaylistId]) {
console.log(`Rehydrating download modal for Deezer playlist: ${stateInfo.playlist_id}`);
try {
const playlistData = deezerPlaylists.find(p => String(p.id) === String(stateInfo.playlist_id));
if (!playlistData) continue;
const spotifyTracks = deezerPlaylistStates[stateInfo.playlist_id]?.discovery_results
?.filter(result => result.spotify_data)
?.map(result => result.spotify_data) || [];
if (spotifyTracks.length > 0) {
await openDownloadMissingModalForTidal(
convertedPlaylistId,
playlistData.name,
spotifyTracks
);
const process = activeDownloadProcesses[convertedPlaylistId];
if (process) {
process.status = 'running';
process.batchId = stateInfo.download_process_id;
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';
startModalDownloadPolling(convertedPlaylistId);
}
}
} catch (error) {
console.error(`Error rehydrating Deezer download modal for ${stateInfo.playlist_id}:`, error);
}
}
}
}
console.log('Deezer playlist states loaded and applied');
} catch (error) {
console.error('Error loading Deezer playlist states:', error);
}
}
async function applyDeezerPlaylistState(stateInfo) {
const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo;
try {
console.log(`π΅ Applying saved state for Deezer playlist: ${playlist_id}, Phase: ${phase}`);
const playlistData = deezerPlaylists.find(p => String(p.id) === String(playlist_id));
if (!playlistData) {
console.warn(`Playlist data not found for state ${playlist_id} - skipping`);
return;
}
if (!deezerPlaylistStates[playlist_id]) {
deezerPlaylistStates[playlist_id] = {
playlist: playlistData,
phase: 'fresh'
};
}
deezerPlaylistStates[playlist_id].phase = phase;
deezerPlaylistStates[playlist_id].discovery_progress = discovery_progress;
deezerPlaylistStates[playlist_id].spotify_matches = spotify_matches;
deezerPlaylistStates[playlist_id].discovery_results = discovery_results;
deezerPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id;
deezerPlaylistStates[playlist_id].download_process_id = download_process_id;
deezerPlaylistStates[playlist_id].playlist = playlistData;
if (phase !== 'fresh' && phase !== 'discovering') {
try {
const stateResponse = await fetch(`/api/deezer/state/${playlist_id}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
if (fullState.discovery_results && deezerPlaylistStates[playlist_id]) {
deezerPlaylistStates[playlist_id].discovery_results = fullState.discovery_results;
deezerPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress;
deezerPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches;
deezerPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
deezerPlaylistStates[playlist_id].download_process_id = fullState.download_process_id;
}
}
} catch (error) {
console.warn(`Error fetching full discovery results for Deezer playlist ${playlistData.name}:`, error.message);
}
}
updateDeezerCardPhase(playlist_id, phase);
if (phase === 'discovered' && deezerPlaylistStates[playlist_id]) {
const progressInfo = {
spotify_total: playlistData.track_count || playlistData.tracks?.length || 0,
spotify_matches: deezerPlaylistStates[playlist_id].spotify_matches || 0
};
updateDeezerCardProgress(playlist_id, progressInfo);
}
if (phase === 'discovering') {
const fakeUrlHash = `deezer_${playlist_id}`;
startDeezerDiscoveryPolling(fakeUrlHash, playlist_id);
} else if (phase === 'syncing') {
const fakeUrlHash = `deezer_${playlist_id}`;
startDeezerSyncPolling(fakeUrlHash);
}
} catch (error) {
console.error(`Error applying Deezer playlist state for ${playlist_id}:`, error);
}
}
function updateDeezerCardPhase(playlistId, phase) {
const state = deezerPlaylistStates[playlistId];
if (!state) return;
state.phase = phase;
const card = document.getElementById(`deezer-card-${playlistId}`);
if (card) {
const newCardHtml = createDeezerCard(state.playlist);
card.outerHTML = newCardHtml;
const newCard = document.getElementById(`deezer-card-${playlistId}`);
if (newCard) {
newCard.addEventListener('click', () => handleDeezerCardClick(playlistId));
}
if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) {
setTimeout(() => {
updateDeezerCardSyncProgress(playlistId, state.lastSyncProgress);
}, 0);
}
}
}
function updateDeezerCardProgress(playlistId, progress) {
const state = deezerPlaylistStates[playlistId];
if (!state) return;
const card = document.getElementById(`deezer-card-${playlistId}`);
if (!card) return;
const progressElement = card.querySelector('.playlist-card-progress');
if (!progressElement) return;
progressElement.classList.remove('hidden');
const total = progress.spotify_total || 0;
const matches = progress.spotify_matches || 0;
if (total > 0) {
progressElement.innerHTML = `
β ${matches}
/
βͺ ${total}
`;
}
}
// ===============================
// DEEZER SYNC FUNCTIONALITY
// ===============================
async function startDeezerPlaylistSync(urlHash) {
try {
console.log('π΅ Starting Deezer playlist sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_deezer_playlist) {
console.error('Invalid Deezer playlist state for sync');
return;
}
const playlistId = state.deezer_playlist_id;
const response = await fetch(`/api/deezer/sync/start/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error starting sync: ${result.error}`, 'error');
return;
}
const syncPlaylistId = result.sync_playlist_id;
if (state) state.syncPlaylistId = syncPlaylistId;
updateDeezerCardPhase(playlistId, 'syncing');
updateDeezerModalButtons(urlHash, 'syncing');
startDeezerSyncPolling(urlHash, syncPlaylistId);
showToast('Deezer playlist sync started!', 'success');
} catch (error) {
console.error('Error starting Deezer sync:', error);
showToast(`Error starting sync: ${error.message}`, 'error');
}
}
function startDeezerSyncPolling(urlHash, syncPlaylistId) {
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
const state = youtubePlaylistStates[urlHash];
const playlistId = state.deezer_playlist_id;
syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId);
// WebSocket subscription
if (socketConnected && syncPlaylistId) {
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
_syncProgressCallbacks[syncPlaylistId] = (data) => {
const progress = data.progress || {};
updateDeezerCardSyncProgress(playlistId, progress);
updateDeezerModalSyncProgress(urlHash, progress);
if (data.status === 'finished') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'sync_complete';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
updateDeezerCardPhase(playlistId, 'sync_complete');
updateDeezerModalButtons(urlHash, 'sync_complete');
showToast('Deezer playlist sync complete!', 'success');
} else if (data.status === 'error' || data.status === 'cancelled') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'discovered';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
updateDeezerCardPhase(playlistId, 'discovered');
updateDeezerModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
}
};
}
const pollFunction = async () => {
if (socketConnected) return;
try {
const response = await fetch(`/api/deezer/sync/status/${playlistId}`);
const status = await response.json();
if (status.error) {
console.error('Error polling Deezer sync status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
updateDeezerCardSyncProgress(playlistId, status.progress);
updateDeezerModalSyncProgress(urlHash, status.progress);
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'sync_complete';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
updateDeezerCardPhase(playlistId, 'sync_complete');
updateDeezerModalButtons(urlHash, 'sync_complete');
showToast('Deezer playlist sync complete!', 'success');
} else if (status.sync_status === 'error') {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'discovered';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
updateDeezerCardPhase(playlistId, 'discovered');
updateDeezerModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Error polling Deezer sync:', error);
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
}
};
if (!socketConnected) pollFunction();
const pollInterval = setInterval(pollFunction, 1000);
activeYouTubePollers[urlHash] = pollInterval;
}
async function cancelDeezerSync(urlHash) {
try {
console.log('Cancelling Deezer sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_deezer_playlist) {
console.error('Invalid Deezer playlist state');
return;
}
const playlistId = state.deezer_playlist_id;
const response = await fetch(`/api/deezer/sync/cancel/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error cancelling sync: ${result.error}`, 'error');
return;
}
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
const syncId = state && state.syncPlaylistId;
if (syncId && _syncProgressCallbacks[syncId]) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] });
delete _syncProgressCallbacks[syncId];
}
updateDeezerCardPhase(playlistId, 'discovered');
updateDeezerModalButtons(urlHash, 'discovered');
showToast('Deezer sync cancelled', 'info');
} catch (error) {
console.error('Error cancelling Deezer sync:', error);
showToast(`Error cancelling sync: ${error.message}`, 'error');
}
}
function updateDeezerCardSyncProgress(playlistId, progress) {
const state = deezerPlaylistStates[playlistId];
if (!state || !state.playlist || !progress) return;
state.lastSyncProgress = progress;
const card = document.getElementById(`deezer-card-${playlistId}`);
if (!card) return;
const progressElement = card.querySelector('.playlist-card-progress');
let statusCounterHTML = '';
if (progress && progress.total_tracks > 0) {
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const processed = matched + failed;
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
statusCounterHTML = `
βͺ ${total}
/
β ${matched}
/
β ${failed}
(${percentage}%)
`;
}
if (statusCounterHTML) {
progressElement.innerHTML = statusCounterHTML;
}
}
function updateDeezerModalSyncProgress(urlHash, progress) {
const statusDisplay = document.getElementById(`deezer-sync-status-${urlHash}`);
if (!statusDisplay || !progress) return;
const totalEl = document.getElementById(`deezer-total-${urlHash}`);
const matchedEl = document.getElementById(`deezer-matched-${urlHash}`);
const failedEl = document.getElementById(`deezer-failed-${urlHash}`);
const percentageEl = document.getElementById(`deezer-percentage-${urlHash}`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
if (total > 0) {
const processed = matched + failed;
const percentage = Math.round((processed / total) * 100);
if (percentageEl) percentageEl.textContent = percentage;
}
}
function updateDeezerModalButtons(urlHash, phase) {
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (!modal) return;
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
}
}
async function startDeezerDownloadMissing(urlHash) {
try {
console.log('π Starting download missing tracks for Deezer playlist:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_deezer_playlist) {
console.error('Invalid Deezer playlist state for download');
return;
}
const discoveryResults = state.discoveryResults || state.discovery_results;
if (!discoveryResults) {
showToast('No discovery results available for download', 'error');
return;
}
const spotifyTracks = [];
for (const result of discoveryResults) {
if (result.spotify_data) {
spotifyTracks.push(result.spotify_data);
} else if (result.spotify_track && result.status_class === 'found') {
const albumData = result.spotify_album || 'Unknown Album';
const albumObject = typeof albumData === 'object' && albumData !== null
? albumData
: {
name: typeof albumData === 'string' ? albumData : 'Unknown Album',
album_type: 'album',
images: []
};
spotifyTracks.push({
id: result.spotify_id || 'unknown',
name: result.spotify_track || 'Unknown Track',
artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'],
album: albumObject,
duration_ms: 0
});
}
}
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
const virtualPlaylistId = `deezer_${state.deezer_playlist_id}`;
const playlistName = state.playlist.name;
state.convertedSpotifyPlaylistId = virtualPlaylistId;
const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (discoveryModal) {
discoveryModal.classList.add('hidden');
}
await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks);
} catch (error) {
console.error('Error starting download missing tracks:', error);
showToast(`Error starting downloads: ${error.message}`, 'error');
}
}
// ===============================
// SYNC PAGE FUNCTIONALITY (REDESIGNED)
// ===============================
function initializeSyncPage() {
// Logic for tab switching
const tabButtons = document.querySelectorAll('.sync-tab-button');
const syncSidebar = document.querySelector('.sync-sidebar');
const syncContentArea = document.querySelector('.sync-content-area');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabId = button.dataset.tab;
const previousActiveTab = document.querySelector('.sync-tab-button.active');
const previousTabId = previousActiveTab ? previousActiveTab.dataset.tab : null;
// Update button active state
tabButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Update content active state
document.querySelectorAll('.sync-tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${tabId}-tab-content`).classList.add('active');
// Show/hide sidebar based on active tab (skip on mobile where sidebar is always hidden)
if (syncSidebar && syncContentArea) {
const isMobile = window.innerWidth <= 1300;
// Sidebar always hidden by default β shown only when sync is active
syncSidebar.style.display = 'none';
syncContentArea.style.gridTemplateColumns = '1fr';
}
// Auto-load Deezer ARL playlists on first tab activation
if (tabId === 'deezer' && !deezerArlPlaylistsLoaded) {
// Check ARL status first
fetch('/api/deezer/arl-status').then(r => r.json()).then(data => {
const container = document.getElementById('deezer-arl-playlist-container');
if (data.authenticated) {
loadDeezerArlPlaylists();
} else if (container) {
container.innerHTML = `Deezer ARL not configured. Add your ARL token in Settings > Downloads to see your playlists here.
`;
}
}).catch(() => { });
}
// Auto-load mirrored playlists on first tab activation
if (tabId === 'mirrored' && !mirroredPlaylistsLoaded) {
loadMirroredPlaylists();
}
// Auto-load server playlists on first tab activation
if (tabId === 'server' && !window._serverPlaylistsLoaded) {
window._serverPlaylistsLoaded = true;
loadServerPlaylists();
}
if (previousTabId === 'beatport' && tabId !== 'beatport') {
cleanupBeatportContent();
}
// Lazily load Beatport content the first time the Beatport tab is opened
if (tabId === 'beatport') {
ensureBeatportContentLoaded();
}
});
});
// If the Beatport tab is already active when Sync initializes, load it now.
const activeBeatportTab = document.querySelector('.sync-tab-button.active[data-tab="beatport"]');
if (activeBeatportTab) {
ensureBeatportContentLoaded();
}
// Logic for the Spotify refresh button
const refreshBtn = document.getElementById('spotify-refresh-btn');
if (refreshBtn) {
// Remove any old listeners to be safe, then add the new one
refreshBtn.removeEventListener('click', loadSpotifyPlaylists);
refreshBtn.addEventListener('click', loadSpotifyPlaylists);
}
// Logic for the Tidal refresh button
const tidalRefreshBtn = document.getElementById('tidal-refresh-btn');
if (tidalRefreshBtn) {
tidalRefreshBtn.removeEventListener('click', loadTidalPlaylists);
tidalRefreshBtn.addEventListener('click', loadTidalPlaylists);
}
// Logic for the Deezer ARL refresh button
const deezerArlRefreshBtn = document.getElementById('deezer-arl-refresh-btn');
if (deezerArlRefreshBtn) {
deezerArlRefreshBtn.removeEventListener('click', loadDeezerArlPlaylists);
deezerArlRefreshBtn.addEventListener('click', loadDeezerArlPlaylists);
}
// Logic for the Deezer Link parse button
const deezerParseBtn = document.getElementById('deezer-parse-btn');
if (deezerParseBtn) {
deezerParseBtn.addEventListener('click', loadDeezerPlaylist);
}
// Also allow Enter key in the Deezer input
const deezerUrlInput = document.getElementById('deezer-url-input');
if (deezerUrlInput) {
deezerUrlInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') loadDeezerPlaylist();
});
}
// Logic for the Mirrored refresh button
const mirroredRefreshBtn = document.getElementById('mirrored-refresh-btn');
if (mirroredRefreshBtn) {
mirroredRefreshBtn.addEventListener('click', loadMirroredPlaylists);
}
// Initialize import file tab
_initImportFileTab();
// Logic for the Beatport clear button
const beatportClearBtn = document.getElementById('beatport-clear-btn');
if (beatportClearBtn) {
beatportClearBtn.addEventListener('click', clearBeatportPlaylists);
// Set initial clear button state
updateBeatportClearButtonState();
}
// Logic for Beatport nested tabs
const beatportTabButtons = document.querySelectorAll('.beatport-tab-button');
beatportTabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabId = button.dataset.beatportTab;
// Update button active state
beatportTabButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Update content active state
document.querySelectorAll('.beatport-tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`beatport-${tabId}-content`).classList.add('active');
// Initialize rebuild content lazily when the rebuild tab is selected
if (tabId === 'rebuild') {
ensureBeatportContentLoaded();
}
});
});
// Logic for Homepage Genre Explorer card
const genreExplorerCard = document.querySelector('[data-action="show-genres"]');
if (genreExplorerCard) {
genreExplorerCard.addEventListener('click', () => {
console.log('π΅ Genre Explorer card clicked');
showBeatportSubView('genres');
loadBeatportGenres();
});
}
// Setup homepage chart handlers (following genre page pattern to prevent duplicates)
setupHomepageChartTypeHandlers();
// Load homepage chart collections automatically (disabled since Browse Charts tab is hidden)
// loadDJChartsInline();
// loadFeaturedChartsInline();
// Logic for Beatport breadcrumb back buttons
const beatportBackButtons = document.querySelectorAll('.breadcrumb-back');
beatportBackButtons.forEach(button => {
button.addEventListener('click', () => {
// Handle different back button types
if (button.id === 'genre-detail-back') {
showBeatportGenresView();
} else if (button.id === 'genre-charts-list-back') {
showBeatportGenreDetailViewFromBack();
} else {
showBeatportMainView();
}
});
});
// Logic for Beatport chart items
const beatportChartItems = document.querySelectorAll('.beatport-chart-item');
beatportChartItems.forEach(item => {
item.addEventListener('click', () => {
const chartType = item.dataset.chartType;
const chartId = item.dataset.chartId;
const chartName = item.dataset.chartName;
const chartEndpoint = item.dataset.chartEndpoint;
handleBeatportChartClick(chartType, chartId, chartName, chartEndpoint);
});
});
// Logic for Beatport genre items
const beatportGenreItems = document.querySelectorAll('.beatport-genre-item');
beatportGenreItems.forEach(item => {
item.addEventListener('click', () => {
const genreSlug = item.dataset.genreSlug;
const genreId = item.dataset.genreId;
handleBeatportGenreClick(genreSlug, genreId);
});
});
// Logic for Rebuild page Top 10 containers - Beatport Top 10
const beatportTop10Container = document.getElementById('beatport-top10-list');
if (beatportTop10Container) {
beatportTop10Container.addEventListener('click', () => {
console.log('π΅ Beatport Top 10 container clicked on rebuild page');
handleRebuildBeatportTop10Click();
});
}
// Logic for Rebuild page Top 10 containers - Hype Top 10
const beatportHype10Container = document.getElementById('beatport-hype10-list');
if (beatportHype10Container) {
beatportHype10Container.addEventListener('click', () => {
console.log('π₯ Hype Top 10 container clicked on rebuild page');
handleRebuildHypeTop10Click();
});
}
// Logic for Rebuild page Hero Slider - individual slide click handlers will be set up in populateBeatportSlider
// Container-level click handler removed to allow individual slide clicks like top 10 releases
// Logic for the Start Sync button
const startSyncBtn = document.getElementById('start-sync-btn');
if (startSyncBtn) {
startSyncBtn.addEventListener('click', startSequentialSync);
}
// Logic for the YouTube parse button
const youtubeParseBtn = document.getElementById('youtube-parse-btn');
if (youtubeParseBtn) {
youtubeParseBtn.addEventListener('click', parseYouTubePlaylist);
}
// Logic for YouTube URL input (Enter key support)
const youtubeUrlInput = document.getElementById('youtube-url-input');
if (youtubeUrlInput) {
youtubeUrlInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
parseYouTubePlaylist();
}
});
}
// Logic for Spotify Public parse button
const spotifyPublicParseBtn = document.getElementById('spotify-public-parse-btn');
if (spotifyPublicParseBtn) {
spotifyPublicParseBtn.addEventListener('click', parseSpotifyPublicUrl);
}
// Logic for Spotify Public URL input (Enter key support)
const spotifyPublicUrlInput = document.getElementById('spotify-public-url-input');
if (spotifyPublicUrlInput) {
spotifyPublicUrlInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
parseSpotifyPublicUrl();
}
});
}
// Logic for Beatport Top 100 button
const beatportTop100Btn = document.getElementById('beatport-top100-btn');
if (beatportTop100Btn) {
beatportTop100Btn.addEventListener('click', handleBeatportTop100Click);
}
// Logic for Hype Top 100 button
const hypeTop100Btn = document.getElementById('hype-top100-btn');
if (hypeTop100Btn) {
hypeTop100Btn.addEventListener('click', handleHypeTop100Click);
}
// Initialize live log viewer
initializeLiveLogViewer();
}
// --- Event Handlers ---
// --- Find and REPLACE the existing handleDbUpdateButtonClick function ---
async function handleDbUpdateButtonClick() {
const button = document.getElementById('db-update-button');
const currentAction = button.textContent;
if (currentAction === 'Update Database') {
const refreshSelect = document.getElementById('db-refresh-type');
const isFullRefresh = refreshSelect.value === 'full';
if (isFullRefresh) {
// Replicates the QMessageBox confirmation from the GUI
const confirmed = await showConfirmDialog({ title: 'Full Refresh', message: 'This will clear and rebuild the database for the active server. It can take a long time.\n\nAre you sure you want to proceed?', confirmText: 'Proceed' });
if (!confirmed) return;
}
try {
button.disabled = true;
button.textContent = 'Starting...';
const response = await fetch('/api/database/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ full_refresh: isFullRefresh })
});
if (response.ok) {
showToast('Database update started!', 'success');
// Start polling immediately to get live status
checkAndUpdateDbProgress();
} else {
const errorData = await response.json();
showToast(`Error: ${errorData.error}`, 'error');
button.disabled = false;
button.textContent = 'Update Database';
}
} catch (error) {
showToast('Failed to start update process.', 'error');
button.disabled = false;
button.textContent = 'Update Database';
}
} else { // "Stop Update"
try {
const response = await fetch('/api/database/update/stop', { method: 'POST' });
if (response.ok) {
showToast('Stop request sent.', 'info');
} else {
showToast('Failed to send stop request.', 'error');
}
} catch (error) {
showToast('Error sending stop request.', 'error');
}
}
}
async function handleWishlistButtonClick() {
try {
const playlistId = 'wishlist';
console.log('π΅ [Wishlist Button] User clicked wishlist button - checking server state first');
// STEP 1: Always check server state first to detect any active wishlist processes
const response = await fetch('/api/active-processes');
if (!response.ok) {
throw new Error(`Failed to fetch active processes: ${response.status}`);
}
const data = await response.json();
const processes = data.active_processes || [];
const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId);
// STEP 2: Handle active server process - show current state immediately
if (serverWishlistProcess) {
console.log('π― [Wishlist Button] Server has active wishlist process:', {
batch_id: serverWishlistProcess.batch_id,
phase: serverWishlistProcess.phase,
auto_initiated: serverWishlistProcess.auto_initiated,
should_show: serverWishlistProcess.should_show_modal
});
// Clear any user-closed state since user explicitly requested to see modal
WishlistModalState.clearUserClosed();
// Check if we need to create/sync the frontend modal
const clientWishlistProcess = activeDownloadProcesses[playlistId];
const needsRehydration = !clientWishlistProcess ||
clientWishlistProcess.batchId !== serverWishlistProcess.batch_id ||
!clientWishlistProcess.modalElement ||
!document.body.contains(clientWishlistProcess.modalElement);
if (needsRehydration) {
console.log('π [Wishlist Button] Frontend modal needs sync/creation');
await rehydrateModal(serverWishlistProcess, true); // user-requested = true
} else {
console.log('β
[Wishlist Button] Frontend modal already synced, showing existing modal');
clientWishlistProcess.modalElement.style.display = 'flex';
WishlistModalState.setVisible();
}
return;
}
// STEP 3: No active server process - check wishlist count and create fresh modal
console.log('π [Wishlist Button] No active server process, checking wishlist content');
const countResponse = await fetch('/api/wishlist/count');
if (!countResponse.ok) {
throw new Error(`Failed to fetch wishlist count: ${countResponse.status}`);
}
const countData = await countResponse.json();
if (countData.count === 0) {
showToast('Wishlist is empty. No tracks to download.', 'info');
return;
}
// STEP 4: Open wishlist overview modal (NEW - category selection)
console.log(`π [Wishlist Button] Opening wishlist overview for ${countData.count} tracks`);
await openWishlistOverviewModal();
} catch (error) {
console.error('β [Wishlist Button] Error handling wishlist button click:', error);
showToast(`Error opening wishlist: ${error.message}`, 'error');
}
}
async function cleanupWishlist(playlistId) {
try {
// Show information dialog
const confirmed = await showConfirmDialog({
title: 'Cleanup Wishlist',
message: 'This will check all wishlist tracks against your music library and automatically remove any tracks that already exist in your database.\n\nThis is a safe operation that only removes tracks you already have. Continue with cleanup?'
});
if (!confirmed) {
return;
}
// Disable the cleanup button during the operation
const cleanupBtn = document.getElementById(`cleanup-wishlist-btn-${playlistId}`);
if (cleanupBtn) {
cleanupBtn.disabled = true;
cleanupBtn.textContent = 'π§Ή Cleaning...';
}
const response = await fetch('/api/wishlist/cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (result.success) {
const removedCount = result.removed_count || 0;
const processedCount = result.processed_count || 0;
if (removedCount > 0) {
showToast(`Wishlist cleanup completed: ${removedCount} tracks removed (${processedCount} checked)`, 'success');
// Refresh the modal content to show updated state
setTimeout(() => {
openDownloadMissingWishlistModal();
}, 500);
// Update the wishlist count in the main dashboard
await updateWishlistCount();
} else {
showToast(`Wishlist cleanup completed: No tracks to remove (${processedCount} checked)`, 'info');
}
} else {
showToast(`Error cleaning wishlist: ${result.error}`, 'error');
}
} catch (error) {
console.error('Error cleaning wishlist:', error);
showToast(`Error cleaning wishlist: ${error.message}`, 'error');
} finally {
// Re-enable the cleanup button
const cleanupBtn = document.getElementById(`cleanup-wishlist-btn-${playlistId}`);
if (cleanupBtn) {
cleanupBtn.disabled = false;
cleanupBtn.textContent = 'π§Ή Cleanup Wishlist';
}
}
}
async function clearWishlist(playlistId) {
try {
// Show confirmation dialog
const confirmed = await showConfirmDialog({
title: 'Clear Wishlist',
message: 'Are you sure you want to clear the entire wishlist?\n\nThis will permanently remove all failed tracks from the wishlist. This action cannot be undone.',
confirmText: 'Clear All',
destructive: true
});
if (!confirmed) {
return;
}
// Disable the clear button during the operation
const clearBtn = document.getElementById(`clear-wishlist-btn-${playlistId}`);
if (clearBtn) {
clearBtn.disabled = true;
clearBtn.textContent = 'Clearing...';
}
// Call the clear API endpoint
const response = await fetch('/api/wishlist/clear', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (result.success) {
showToast('Wishlist cleared successfully', 'success');
// Close the modal since there are no more tracks
closeDownloadMissingModal(playlistId);
// Update the wishlist count in the main dashboard
await updateWishlistCount();
} else {
showToast(`Failed to clear wishlist: ${result.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Error clearing wishlist:', error);
showToast(`Error clearing wishlist: ${error.message}`, 'error');
} finally {
// Re-enable the clear button
const clearBtn = document.getElementById(`clear-wishlist-btn-${playlistId}`);
if (clearBtn) {
clearBtn.disabled = false;
clearBtn.textContent = 'ποΈ Clear Wishlist';
}
}
}
// ===============================
// BEATPORT CHARTS FUNCTIONALITY
// ===============================
function updateBeatportClearButtonState() {
const clearBtn = document.getElementById('beatport-clear-btn');
if (!clearBtn) return;
// Check if any Beatport cards are in active states
const activeCharts = Object.values(beatportChartStates).filter(state =>
state.phase === 'discovering' || state.phase === 'syncing' || state.phase === 'downloading'
);
const hasActiveCharts = activeCharts.length > 0;
const hasAnyCharts = Object.keys(beatportChartStates).length > 0;
if (!hasAnyCharts) {
// No charts at all
clearBtn.disabled = true;
clearBtn.textContent = 'ποΈ Clear';
clearBtn.style.opacity = '0.5';
clearBtn.style.cursor = 'not-allowed';
clearBtn.title = 'No Beatport charts to clear';
} else if (hasActiveCharts) {
// Has charts but some are active
clearBtn.disabled = true;
clearBtn.textContent = 'π« Clear Blocked';
clearBtn.style.opacity = '0.6';
clearBtn.style.cursor = 'not-allowed';
const activeNames = activeCharts.map(state => state.chart?.name || 'Unknown').join(', ');
clearBtn.title = `Cannot clear: ${activeCharts.length} chart(s) are currently active: ${activeNames}`;
} else {
// Has charts and none are active
clearBtn.disabled = false;
clearBtn.textContent = 'ποΈ Clear';
clearBtn.style.opacity = '1';
clearBtn.style.cursor = 'pointer';
clearBtn.title = 'Clear all Beatport charts';
}
}
async function clearBeatportPlaylists() {
const container = document.getElementById('beatport-playlist-container');
const clearBtn = document.getElementById('beatport-clear-btn');
if (Object.keys(beatportChartStates).length === 0) {
showToast('No Beatport playlists to clear', 'info');
return;
}
// Check if any Beatport cards are in active states (discovering, syncing, or downloading)
const activeCharts = Object.values(beatportChartStates).filter(state =>
state.phase === 'discovering' || state.phase === 'syncing' || state.phase === 'downloading'
);
if (activeCharts.length > 0) {
const activeNames = activeCharts.map(state => state.chart?.name || 'Unknown').join(', ');
showToast(`Cannot clear: ${activeCharts.length} chart(s) are currently discovering, syncing, or downloading: ${activeNames}`, 'warning');
return;
}
// Show loading state
clearBtn.disabled = true;
clearBtn.textContent = 'ποΈ Clearing...';
try {
// Clear all Beatport chart states
Object.keys(beatportChartStates).forEach(chartHash => {
// Close any open modals for this chart
const modal = document.getElementById(`youtube-discovery-modal-${chartHash}`);
if (modal) {
modal.remove();
}
// Remove from YouTube states (since Beatport reuses that infrastructure)
if (youtubePlaylistStates[chartHash]) {
// Clean up any active download processes for this Beatport chart
const ytState = youtubePlaylistStates[chartHash];
if (ytState.is_beatport_playlist && ytState.convertedSpotifyPlaylistId) {
const downloadProcess = activeDownloadProcesses[ytState.convertedSpotifyPlaylistId];
if (downloadProcess) {
console.log(`ποΈ Cleaning up download process for Beatport chart: ${chartHash}`);
if (downloadProcess.modalElement) {
downloadProcess.modalElement.remove();
}
delete activeDownloadProcesses[ytState.convertedSpotifyPlaylistId];
}
}
delete youtubePlaylistStates[chartHash];
}
});
// Clear Beatport states
const chartHashesToClear = Object.keys(beatportChartStates);
beatportChartStates = {};
// Clear backend state for all charts
for (const chartHash of chartHashesToClear) {
try {
await fetch(`/api/beatport/charts/delete/${chartHash}`, {
method: 'DELETE'
});
console.log(`ποΈ Deleted backend state for Beatport chart: ${chartHash}`);
} catch (error) {
console.warn(`β οΈ Error deleting backend state for chart ${chartHash}:`, error);
}
}
// Reset container to placeholder
container.innerHTML = `
Your created Beatport playlists will appear here.
`;
console.log(`ποΈ Cleared ${chartHashesToClear.length} Beatport charts from frontend and backend`);
showToast('Cleared all Beatport playlists', 'success');
// Update clear button state after clearing all charts
updateBeatportClearButtonState();
} catch (error) {
console.error('Error clearing Beatport playlists:', error);
showToast(`Error clearing playlists: ${error.message}`, 'error');
} finally {
clearBtn.disabled = false;
clearBtn.textContent = 'ποΈ Clear';
}
}
function handleBeatportCategoryClick(category) {
console.log(`π΅ Beatport category clicked: ${category}`);
// Only handle genres category now - homepage has direct chart buttons
switch (category) {
case 'genres':
showBeatportSubView('genres');
loadBeatportGenres(); // Load genres dynamically
break;
default:
showToast(`Unknown category: ${category}`, 'error');
}
}
async function loadBeatportGenres() {
console.log('π Loading Beatport genres dynamically...');
const genreGrid = document.querySelector('#beatport-genres-view .beatport-genre-grid');
if (!genreGrid) {
console.error('β Could not find genre grid element');
return;
}
// Show loading state
genreGrid.innerHTML = `
π Discovering current Beatport genres...
`;
try {
// First, fetch genres quickly without images
console.log('π Fetching genres without images for fast loading...');
const fastResponse = await fetch('/api/beatport/genres');
if (!fastResponse.ok) {
throw new Error(`API returned ${fastResponse.status}: ${fastResponse.statusText}`);
}
const fastData = await fastResponse.json();
const genres = fastData.genres || [];
if (genres.length === 0) {
genreGrid.innerHTML = `
β οΈ No genres available
π Retry
`;
return;
}
// Generate genre cards dynamically (without images first)
const genreCardsHTML = genres.map(genre => `
π΅
${genre.name}
Top 100
`).join('');
genreGrid.innerHTML = genreCardsHTML;
// Add click handlers to dynamically created genre items
const genreItems = genreGrid.querySelectorAll('.beatport-genre-item');
genreItems.forEach(item => {
item.addEventListener('click', () => {
const genreSlug = item.dataset.genreSlug;
const genreId = item.dataset.genreId;
const genreName = item.dataset.genreName;
handleBeatportGenreClick(genreSlug, genreId, genreName);
});
});
console.log(`β
Loaded ${genres.length} Beatport genres dynamically (fast mode)`);
showToast(`Loaded ${genres.length} current Beatport genres`, 'success');
// Now fetch images progressively in the background if there are many genres
if (genres.length > 10) {
console.log('πΌοΈ Loading genre images progressively...');
loadGenreImagesProgressively(genres);
}
} catch (error) {
console.error('β Error loading Beatport genres:', error);
genreGrid.innerHTML = `
β Failed to load genres: ${error.message}
π Retry
`;
showToast(`Error loading Beatport genres: ${error.message}`, 'error');
}
}
async function loadGenreImagesProgressively(genres) {
// Load genre images with 2 concurrent workers for faster loading
const imageQueue = [...genres]; // Create a copy for processing
let imagesLoaded = 0;
const maxWorkers = 2;
console.log(`πΌοΈ Starting progressive image loading with ${maxWorkers} workers for ${imageQueue.length} genres`);
// Function to process a single image
async function processImage(genre) {
try {
// Fetch individual genre image from backend
const response = await fetch(`/api/beatport/genre-image/${genre.slug}/${genre.id}`);
if (response.ok) {
const data = await response.json();
if (data.success && data.image_url) {
// Find the genre item in the DOM
const genreItem = document.querySelector(
`[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]`
);
if (genreItem) {
const iconElement = genreItem.querySelector('.genre-icon');
if (iconElement) {
// Create new image element with smooth transition
const imageDiv = document.createElement('div');
imageDiv.className = 'genre-image';
imageDiv.style.backgroundImage = `url('${data.image_url}')`;
imageDiv.style.opacity = '0';
imageDiv.style.transition = 'opacity 0.3s ease';
// Replace icon with image
iconElement.replaceWith(imageDiv);
// Trigger fade-in animation
setTimeout(() => {
imageDiv.style.opacity = '1';
}, 50);
imagesLoaded++;
console.log(`πΌοΈ [${imagesLoaded}/${imageQueue.length}] Loaded image for ${genre.name}`);
}
}
}
}
} catch (error) {
console.warn(`β οΈ Failed to load image for ${genre.name}:`, error);
}
}
// Worker function that processes images from the queue
async function imageWorker(workerId) {
while (imageQueue.length > 0) {
const genre = imageQueue.shift(); // Take next image from queue
if (genre) {
await processImage(genre);
// Small delay between requests to be respectful (500ms per worker = ~2 images per second total)
await new Promise(resolve => setTimeout(resolve, 500));
}
}
console.log(`β
Worker ${workerId} finished`);
}
// Start the workers
const workers = [];
for (let i = 0; i < maxWorkers; i++) {
workers.push(imageWorker(i + 1));
}
// Wait for all workers to complete
await Promise.all(workers);
console.log(`β
Progressive image loading complete: ${imagesLoaded}/${genres.length} images loaded`);
}
function setupHomepageChartTypeHandlers() {
console.log('π§ Setting up homepage chart type handlers...');
// Select all homepage chart type cards (following genre page pattern)
const chartTypeCards = document.querySelectorAll('.homepage-main-charts-section .genre-chart-type-card[data-chart-type], .homepage-releases-section .genre-chart-type-card[data-chart-type], .homepage-hype-section .genre-chart-type-card[data-chart-type]');
chartTypeCards.forEach(card => {
// Remove existing listeners by cloning (following genre page pattern)
card.replaceWith(card.cloneNode(true));
});
// Re-select after cloning to ensure clean event listeners (following genre page pattern)
const newChartTypeCards = document.querySelectorAll('.homepage-main-charts-section .genre-chart-type-card[data-chart-type], .homepage-releases-section .genre-chart-type-card[data-chart-type], .homepage-hype-section .genre-chart-type-card[data-chart-type]');
newChartTypeCards.forEach(card => {
card.addEventListener('click', () => {
const chartType = card.dataset.chartType;
const chartEndpoint = card.dataset.chartEndpoint;
const chartName = card.querySelector('.chart-type-info h3').textContent;
console.log(`π₯ Homepage chart clicked: ${chartName} (${chartType})`);
handleHomepageChartTypeClick(chartType, chartEndpoint, chartName);
});
});
console.log(`β
Setup ${newChartTypeCards.length} homepage chart handlers`);
}
async function handleHomepageChartTypeClick(chartType, chartEndpoint, chartName) {
console.log(`π₯ Homepage chart type clicked: ${chartType} (${chartName})`);
// Map chart types to API endpoints and create descriptive names (following genre page pattern)
const chartTypeMap = {
'top-10': {
endpoint: `/api/beatport/top-100`, // Use top-100 endpoint and limit to 10
name: `Beatport Top 10`,
limit: 10
},
'top-100': {
endpoint: `/api/beatport/top-100`,
name: `Beatport Top 100`,
limit: 100
},
'releases-top-10': {
endpoint: `/api/beatport/homepage/top-10-releases`, // Working route
name: `Top 10 Releases`,
limit: 10
},
'releases-top-100': {
endpoint: `/api/beatport/top-100-releases`,
name: `Top 100 Releases`,
limit: 100
},
'latest-releases': {
endpoint: `/api/beatport/homepage/new-releases`, // Use new-releases as fallback for now
name: `Latest Releases`,
limit: 50
},
'hype-top-10': {
endpoint: `/api/beatport/hype-top-100`, // Use hype-100 endpoint and limit to 10
name: `Hype Top 10`,
limit: 10
},
'hype-top-100': {
endpoint: `/api/beatport/hype-top-100`,
name: `Hype Top 100`,
limit: 100
},
'hype-picks': {
endpoint: `/api/beatport/homepage/hype-picks`, // Working route
name: `Hype Picks`,
limit: 50
}
};
const chartConfig = chartTypeMap[chartType];
if (!chartConfig) {
console.error(`β Unknown homepage chart type: ${chartType}`);
showToast(`Unknown chart type: ${chartType}`, 'error');
return;
}
try {
showToast(`Loading ${chartConfig.name}...`, 'info');
showLoadingOverlay(`Loading ${chartConfig.name}...`);
const response = await fetch(`${chartConfig.endpoint}?limit=${chartConfig.limit}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${chartConfig.name}: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error(`No tracks found in ${chartConfig.name}`);
}
console.log(`β
Fetched ${data.tracks.length} tracks from ${chartConfig.name}`);
hideLoadingOverlay();
openBeatportChartAsDownloadModal(data.tracks, chartConfig.name, null);
} catch (error) {
console.error(`β Error loading ${chartConfig.name}:`, error);
hideLoadingOverlay();
showToast(`Error loading ${chartConfig.name}: ${error.message}`, 'error');
}
}
async function openBeatportDiscoveryModal(chartHash, chartData) {
console.log(`π΅ Opening Beatport discovery modal (reusing YouTube modal): ${chartData.name}`);
// Create YouTube-style state entry for this Beatport chart
const beatportState = {
phase: 'fresh',
playlist: {
name: chartData.name,
tracks: chartData.tracks,
description: `${chartData.track_count} tracks from ${chartData.name}`,
source: 'beatport'
},
is_beatport_playlist: true,
beatport_chart_type: chartData.chart_type,
beatport_chart_hash: chartHash // Link to Beatport card state
};
// Store in YouTube playlist states (reusing the infrastructure)
youtubePlaylistStates[chartHash] = beatportState;
// Start discovery automatically (like Tidal does)
try {
console.log(`π Starting Beatport discovery for: ${chartData.name}`);
// Update card phase to discovering immediately
updateBeatportCardPhase(chartHash, 'discovering');
// Call the discovery start endpoint with chart data
const response = await fetch(`/api/beatport/discovery/start/${chartHash}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
chart_data: chartData
})
});
const result = await response.json();
if (result.success) {
// Update state to discovering
youtubePlaylistStates[chartHash].phase = 'discovering';
// Start polling for progress
startBeatportDiscoveryPolling(chartHash);
console.log(`β
Started Beatport discovery for: ${chartData.name}`);
} else {
console.error('β Error starting Beatport discovery:', result.error);
showToast(`Error starting discovery: ${result.error}`, 'error');
// Revert card phase on error
updateBeatportCardPhase(chartHash, 'fresh');
}
} catch (error) {
console.error('β Error starting Beatport discovery:', error);
showToast(`Error starting discovery: ${error.message}`, 'error');
// Revert card phase on error
updateBeatportCardPhase(chartHash, 'fresh');
}
// Open the existing YouTube discovery modal infrastructure
openYouTubeDiscoveryModal(chartHash);
console.log(`β
Beatport discovery modal opened for ${chartData.name} with ${chartData.tracks.length} tracks`);
}
function startBeatportDiscoveryPolling(urlHash) {
console.log(`π Starting Beatport discovery polling for: ${urlHash}`);
// Stop any existing polling (reuse YouTube polling infrastructure)
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('discovery:subscribe', { ids: [urlHash] });
_discoveryProgressCallbacks[urlHash] = (data) => {
if (data.error) {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash];
return;
}
if (youtubePlaylistStates[urlHash]) {
const transformed = {
progress: data.progress || 0, spotify_matches: data.spotify_matches || 0, spotify_total: data.spotify_total || 0,
results: (data.results || []).map((r, i) => ({
index: r.index !== undefined ? r.index : i,
yt_track: r.beatport_track ? r.beatport_track.title : 'Unknown',
yt_artist: r.beatport_track ? r.beatport_track.artist : 'Unknown',
status: (r.status === 'found' || r.status === 'β
Found' || r.status_class === 'found') ? 'β
Found' : (r.status === 'error' ? 'β Error' : 'β Not Found'),
status_class: r.status_class || ((r.status === 'found' || r.status === 'β
Found') ? 'found' : (r.status === 'error' ? 'error' : 'not-found')),
spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'),
spotify_artist: r.spotify_data && r.spotify_data.artists ? r.spotify_data.artists.map(a => a.name || a).join(', ') : (r.spotify_artist || '-'),
spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'),
spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match
}))
};
const st = youtubePlaylistStates[urlHash];
st.discovery_progress = data.progress; st.discoveryProgress = data.progress;
st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches;
st.discovery_results = data.results; st.discoveryResults = transformed.results;
st.phase = data.phase || 'discovering';
const chartHash = st.beatport_chart_hash || urlHash;
updateBeatportCardPhase(chartHash, data.phase || 'discovering');
updateBeatportCardProgress(chartHash, { spotify_total: data.spotify_total || 0, spotify_matches: data.spotify_matches || 0, failed: (data.spotify_total || 0) - (data.spotify_matches || 0) });
if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = data.phase || 'discovering';
updateYouTubeDiscoveryModal(urlHash, transformed);
}
if (data.phase === 'discovered' || data.phase === 'error') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash];
}
};
}
const pollInterval = setInterval(async () => {
// Always poll β no dedicated WebSocket events for discovery progress
try {
const response = await fetch(`/api/beatport/discovery/status/${urlHash}`);
const status = await response.json();
if (status.error) {
console.error('β Error polling Beatport discovery status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
// Update state and modal (reuse YouTube infrastructure like Tidal)
if (youtubePlaylistStates[urlHash]) {
// Transform Beatport results to YouTube modal format (like Tidal does)
const transformedStatus = {
progress: status.progress || 0,
spotify_matches: status.spotify_matches || 0,
spotify_total: status.spotify_total || 0,
results: (status.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' || 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 ?
result.spotify_data.artists.map(a => a.name || a).join(', ') : (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, // Pass through
spotify_id: result.spotify_id, // Pass through
manual_match: result.manual_match // Pass through
}))
};
// Update state with both backend and frontend formats (like Tidal)
const state = youtubePlaylistStates[urlHash];
state.discovery_progress = status.progress; // Backend format
state.discoveryProgress = status.progress; // Frontend format - for modal progress display
state.spotify_matches = status.spotify_matches; // Backend format
state.spotifyMatches = status.spotify_matches; // Frontend format - for button logic
state.discovery_results = status.results; // Backend format
state.discoveryResults = transformedStatus.results; // Frontend format - for button logic
state.phase = status.phase || 'discovering';
// Update Beatport card phase and progress
const chartHash = state.beatport_chart_hash || urlHash;
updateBeatportCardPhase(chartHash, status.phase || 'discovering');
updateBeatportCardProgress(chartHash, {
spotify_total: status.spotify_total || 0,
spotify_matches: status.spotify_matches || 0,
failed: (status.spotify_total || 0) - (status.spotify_matches || 0)
});
// Sync with backend Beatport chart state
if (beatportChartStates[chartHash]) {
beatportChartStates[chartHash].phase = status.phase || 'discovering';
}
// Update modal display with transformed data
updateYouTubeDiscoveryModal(urlHash, transformedStatus);
}
// Stop polling when discovery is complete
if (status.phase === 'discovered' || status.phase === 'error') {
console.log(`β
Beatport discovery polling complete for: ${urlHash}`);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
}
} catch (error) {
console.error('β Error polling Beatport discovery:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
}
}, 2000); // Poll every 2 seconds like Tidal
// Store the interval so we can clean it up later
activeYouTubePollers[urlHash] = pollInterval;
}
function showBeatportSubView(viewType) {
// Hide main category view
const mainView = document.getElementById('beatport-main-view');
if (mainView) {
mainView.classList.remove('active');
}
// Hide all sub-views
document.querySelectorAll('.beatport-sub-view').forEach(view => {
view.classList.remove('active');
});
// Show the requested sub-view
const targetView = document.getElementById(`beatport-${viewType}-view`);
if (targetView) {
targetView.classList.add('active');
console.log(`π΅ Showing Beatport ${viewType} view`);
} else {
console.error(`π΅ Could not find view: beatport-${viewType}-view`);
}
}
function showBeatportMainView() {
// Hide all sub-views
document.querySelectorAll('.beatport-sub-view').forEach(view => {
view.classList.remove('active');
});
// Show main category view
const mainView = document.getElementById('beatport-main-view');
if (mainView) {
mainView.classList.add('active');
console.log('π΅ Showing Beatport main view');
}
}
// ===============================
// REBUILD PAGE TOP 10 FUNCTIONALITY
// ===============================
// Global variable to store rebuild page track data for reuse
let rebuildPageTrackData = {
beatport_top10: null,
hype_top10: null
// hero_slider removed - now uses individual slide click handlers
};
async function handleRebuildBeatportTop10Click() {
console.log('π΅ Handling Beatport Top 10 click on rebuild page');
// Use the existing chart creation pattern from Browse Charts EXACTLY
await handleRebuildChartClick('beatport_top10', 'Beatport Top 10', 'rebuild_beatport_top10');
}
async function handleRebuildHypeTop10Click() {
console.log('π₯ Handling Hype Top 10 click on rebuild page');
// Use the existing chart creation pattern from Browse Charts EXACTLY
await handleRebuildChartClick('hype_top10', 'Hype Top 10', 'rebuild_hype_top10');
}
// Hero slider now uses individual slide click handlers instead of container-level clicking
// The old handleRebuildHeroSliderClick function has been removed in favor of individual release discovery
async function handleRebuildChartClick(trackDataKey, chartName, chartType) {
if (_beatportModalOpening) return;
_beatportModalOpening = true;
setTimeout(() => { _beatportModalOpening = false; }, 2000);
try {
// Get basic track data from DOM
const trackData = await getRebuildPageTrackData(trackDataKey);
if (!trackData || trackData.length === 0) {
throw new Error(`No track data found for ${chartName}`);
}
console.log(`β
Got ${trackData.length} tracks from ${chartName}, enriching one-by-one...`);
showLoadingOverlay(`Fetching track metadata... (0/${trackData.length})`);
const enrichedTracks = await _enrichTracksWithProgress(trackData, chartName);
console.log(`β
Enriched ${enrichedTracks.length} tracks`);
hideLoadingOverlay();
openBeatportChartAsDownloadModal(enrichedTracks, chartName, null);
} catch (error) {
hideLoadingOverlay();
console.error(`β Error handling ${chartName} click:`, error);
showToast(`Error loading ${chartName}: ${error.message}`, 'error');
}
}
async function getRebuildPageTrackData(trackDataKey) {
// First check if we have cached data from when the rebuild page was loaded
if (rebuildPageTrackData[trackDataKey]) {
console.log(`π¦ Using cached ${trackDataKey} data`);
return rebuildPageTrackData[trackDataKey];
}
// If no cached data, extract from DOM (fallback)
console.log(`π Extracting ${trackDataKey} data from rebuild page DOM`);
let containerSelector, cardSelector;
if (trackDataKey === 'beatport_top10') {
containerSelector = '#beatport-top10-list';
cardSelector = '.beatport-top10-card[data-url]';
} else if (trackDataKey === 'hype_top10') {
containerSelector = '#beatport-hype10-list';
cardSelector = '.beatport-hype10-card[data-url]';
} else {
throw new Error(`Unknown track data key: ${trackDataKey}`);
}
const container = document.querySelector(containerSelector);
if (!container) {
throw new Error(`Container ${containerSelector} not found`);
}
const trackCards = container.querySelectorAll(cardSelector);
if (trackCards.length === 0) {
throw new Error(`No track cards found in ${containerSelector}`);
}
// Extract track data from DOM cards
const tracks = Array.from(trackCards).map(card => {
const title = card.querySelector('.beatport-top10-card-title, .beatport-hype10-card-title')?.textContent?.trim() || 'Unknown Title';
const artist = card.querySelector('.beatport-top10-card-artist, .beatport-hype10-card-artist')?.textContent?.trim() || 'Unknown Artist';
const label = card.querySelector('.beatport-top10-card-label, .beatport-hype10-card-label')?.textContent?.trim() || 'Unknown Label';
const url = card.getAttribute('data-url') || '';
const rank = card.querySelector('.beatport-top10-card-rank, .beatport-hype10-card-rank')?.textContent?.trim() || '';
return {
title: title,
artist: artist,
label: label,
url: url,
rank: rank
};
});
console.log(`π Extracted ${tracks.length} tracks from ${containerSelector}`);
// Cache for future use
rebuildPageTrackData[trackDataKey] = tracks;
return tracks;
}
// getHeroSliderTrackData function removed - hero slider now uses individual slide click handlers
// Each slide will create its own discovery modal using handleBeatportReleaseCardClick
// Hook into the loadBeatportTop10Lists function to cache track data
const originalLoadBeatportTop10Lists = window.loadBeatportTop10Lists;
if (originalLoadBeatportTop10Lists) {
window.loadBeatportTop10Lists = async function () {
const result = await originalLoadBeatportTop10Lists.apply(this, arguments);
// If the load was successful, we can potentially cache the track data
// But for now, we'll rely on DOM extraction as it's more reliable
return result;
};
}
// ===============================
// BEATPORT CHART FUNCTIONALITY
// ===============================
function createBeatportCard(chartData) {
const state = beatportChartStates[chartData.hash];
const phase = state ? state.phase : 'fresh';
let buttonText = getActionButtonText(phase);
let phaseText = getPhaseText(phase);
let phaseColor = getPhaseColor(phase);
return `
π§
${escapeHtml(chartData.name)}
${chartData.track_count} tracks
${phaseText}
${buttonText}
`;
}
function addBeatportCardToContainer(chartData) {
const container = document.getElementById('beatport-playlist-container');
// Remove placeholder if it exists
const placeholder = container.querySelector('.playlist-placeholder');
if (placeholder) {
placeholder.remove();
}
// Check if card already exists
const existingCard = document.getElementById(`beatport-card-${chartData.hash}`);
if (existingCard) {
console.log(`Card already exists for ${chartData.name}, updating instead`);
return;
}
// Create and add the card
const cardHtml = createBeatportCard(chartData);
container.insertAdjacentHTML('beforeend', cardHtml);
// Initialize state
beatportChartStates[chartData.hash] = {
phase: 'fresh',
chart: chartData,
cardElement: document.getElementById(`beatport-card-${chartData.hash}`)
};
// Add click handler
const card = document.getElementById(`beatport-card-${chartData.hash}`);
if (card) {
card.addEventListener('click', async () => await handleBeatportCardClick(chartData.hash));
}
console.log(`π Created Beatport card: ${chartData.name}`);
// Auto-mirror this Beatport chart
if (chartData.tracks && chartData.tracks.length > 0) {
mirrorPlaylist('beatport', chartData.hash, chartData.name, chartData.tracks.map(t => ({
track_name: t.name || t.title || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artist || ''),
album_name: t.album || '', duration_ms: t.duration_ms || 0,
source_track_id: t.id || '', image_url: t.image_url || null
})));
}
// Update clear button state after creating card
updateBeatportClearButtonState();
}
async function handleBeatportCardClick(chartHash) {
const state = beatportChartStates[chartHash];
if (!state) {
console.error(`β [Card Click] No state found for Beatport chart: ${chartHash}`);
showToast('Chart state not found - try refreshing the page', 'error');
return;
}
if (!state.chart) {
console.error(`β [Card Click] No chart data found for Beatport chart: ${chartHash}`);
showToast('Chart data missing - try refreshing the page', 'error');
return;
}
console.log(`π§ [Card Click] Beatport card clicked: ${chartHash}, Phase: ${state.phase}`);
if (state.phase === 'fresh') {
// Open discovery modal and start discovery
openBeatportDiscoveryModal(chartHash, state.chart);
} else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') {
// Reopen existing modal with preserved discovery results
console.log(`π§ [Card Click] Opening Beatport discovery modal for ${state.phase} phase`);
// Check if we have the required state data
const ytState = youtubePlaylistStates[chartHash];
if (!ytState || !ytState.playlist) {
console.log(`π [Card Click] Missing playlist data for ${state.phase} phase, fetching from backend...`);
try {
// Fetch the full state from backend
const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
// Restore the missing playlist data
if (fullState.chart_data) {
if (!youtubePlaylistStates[chartHash]) {
youtubePlaylistStates[chartHash] = {};
}
youtubePlaylistStates[chartHash].playlist = fullState.chart_data;
youtubePlaylistStates[chartHash].is_beatport_playlist = true;
youtubePlaylistStates[chartHash].beatport_chart_hash = chartHash;
// Also restore discovery results if available
if (fullState.discovery_results) {
youtubePlaylistStates[chartHash].discovery_results = fullState.discovery_results;
console.log(`π [Hydration] Restored ${fullState.discovery_results.length} discovery results`);
console.log(`π [Hydration] First result:`, fullState.discovery_results[0]);
}
// Restore discovery progress state
if (fullState.discovery_progress !== undefined) {
youtubePlaylistStates[chartHash].discovery_progress = fullState.discovery_progress;
}
if (fullState.spotify_matches !== undefined) {
youtubePlaylistStates[chartHash].spotify_matches = fullState.spotify_matches;
console.log(`π [Hydration] Restored spotify_matches: ${fullState.spotify_matches}`);
}
if (fullState.spotify_total !== undefined) {
youtubePlaylistStates[chartHash].spotify_total = fullState.spotify_total;
}
console.log(`β
[Card Click] Restored playlist data for ${state.phase} phase`);
}
} else {
console.error(`β [Card Click] Failed to fetch state for chart: ${chartHash}`);
showToast('Error loading chart data', 'error');
return;
}
} catch (error) {
console.error(`β [Card Click] Error fetching chart state:`, error);
showToast('Error loading chart data', 'error');
return;
}
}
openYouTubeDiscoveryModal(chartHash);
// If still in discovering phase, start polling for live updates
if (state.phase === 'discovering') {
console.log(`π [Card Click] Starting discovery polling for ${state.phase} phase`);
// Let the polling handle all modal updates to avoid data structure mismatches
console.log(`π [Card Click] Starting polling - it will update modal with current progress`);
startBeatportDiscoveryPolling(chartHash);
}
} else if (state.phase === 'downloading' || state.phase === 'download_complete') {
// Open download modal if we have the converted playlist ID (following YouTube/Tidal pattern)
const ytState = youtubePlaylistStates[chartHash];
if (ytState && ytState.is_beatport_playlist && ytState.convertedSpotifyPlaylistId) {
console.log(`π₯ [Card Click] Opening download modal for Beatport chart: ${ytState.playlist.name} (phase: ${state.phase})`);
// Check if modal already exists, if not create it (like Tidal implementation)
if (activeDownloadProcesses[ytState.convertedSpotifyPlaylistId]) {
const process = activeDownloadProcesses[ytState.convertedSpotifyPlaylistId];
if (process.modalElement) {
console.log(`π± [Card Click] Showing existing download modal for ${state.phase} phase`);
process.modalElement.style.display = 'flex';
} else {
console.warn(`β οΈ [Card Click] Download process exists but modal element missing - rehydrating`);
await rehydrateBeatportDownloadModal(chartHash, ytState);
}
} else {
// Need to create the download modal - fetch the discovery results if needed
console.log(`π§ [Card Click] Rehydrating Beatport download modal for ${state.phase} phase`);
await rehydrateBeatportDownloadModal(chartHash, ytState);
}
} else {
console.error('β [Card Click] No converted Spotify playlist ID found for Beatport download modal');
console.log('π [Card Click] Available state data:', Object.keys(ytState || {}));
// Fallback: try to open discovery modal if we have discovery results
if (ytState && ytState.discovery_results && ytState.discovery_results.length > 0) {
console.log(`π [Card Click] Fallback: Opening discovery modal with ${ytState.discovery_results.length} results`);
openYouTubeDiscoveryModal(chartHash);
} else {
showToast('Unable to open download modal - missing playlist data', 'error');
}
}
}
}
async function rehydrateBeatportDownloadModal(chartHash, ytState) {
try {
console.log(`π§ [Rehydration] Attempting fallback rehydration for Beatport chart: ${chartHash}`);
// This function is only called as a fallback when the modal wasn't created during backend loading
// In most cases, the modal should already exist from loadBeatportChartsFromBackend()
if (!ytState || !ytState.playlist || !ytState.convertedSpotifyPlaylistId) {
console.error(`β [Rehydration] Invalid state data for Beatport chart: ${chartHash}`);
showToast('Cannot open download modal - invalid playlist data', 'error');
return;
}
// Get discovery results from backend if not already loaded
if (!ytState.discovery_results) {
console.log(`π Fetching discovery results from backend for Beatport chart: ${chartHash}`);
const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
ytState.discovery_results = fullState.discovery_results;
ytState.download_process_id = fullState.download_process_id;
console.log(`β
Loaded ${fullState.discovery_results?.length || 0} discovery results from backend`);
} else {
console.error('β Failed to fetch Beatport discovery results from backend');
showToast('Error loading playlist data', 'error');
return;
}
}
// Extract Spotify tracks from discovery results
const 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) {
console.error('β No Spotify tracks found for download modal');
showToast('No Spotify matches found for download', 'error');
return;
}
const virtualPlaylistId = ytState.convertedSpotifyPlaylistId;
const playlistName = ytState.playlist.name;
// Create the download modal
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
// Set up the modal for the running state if we have a download process ID
if (ytState.download_process_id) {
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = 'running';
process.batchId = ytState.download_process_id;
// Update UI to reflect running state
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';
// Start polling for this process
startModalDownloadPolling(virtualPlaylistId);
console.log(`β
[Rehydration] Fallback modal rehydrated for running download process`);
}
}
} catch (error) {
console.error(`β [Rehydration] Error in fallback rehydration for Beatport chart:`, error);
showToast('Error opening download modal', 'error');
hideLoadingOverlay();
}
}
function updateBeatportCardPhase(chartHash, phase) {
const state = beatportChartStates[chartHash];
if (!state) return;
state.phase = phase;
// Re-render the card with new phase
const card = document.getElementById(`beatport-card-${chartHash}`);
if (card) {
const newCardHtml = createBeatportCard(state.chart);
card.outerHTML = newCardHtml;
// Re-attach click handler
const newCard = document.getElementById(`beatport-card-${chartHash}`);
if (newCard) {
newCard.addEventListener('click', async () => await handleBeatportCardClick(chartHash));
state.cardElement = newCard;
}
}
// Update clear button state after phase change
updateBeatportClearButtonState();
}
function updateBeatportCardProgress(chartHash, progress) {
const state = beatportChartStates[chartHash];
if (!state) return;
const card = document.getElementById(`beatport-card-${chartHash}`);
if (!card) return;
const progressElement = card.querySelector('.playlist-card-progress');
if (!progressElement) return;
const { spotify_total, spotify_matches, failed } = progress;
const percentage = spotify_total > 0 ? Math.round((spotify_matches / spotify_total) * 100) : 0;
progressElement.textContent = `βͺ ${spotify_total} / β ${spotify_matches} / β ${failed} / ${percentage}%`;
progressElement.classList.remove('hidden');
console.log('π§ Updated Beatport card progress:', chartHash, `${spotify_matches}/${spotify_total} (${percentage}%)`);
}
function switchToBeatportPlaylistsTab() {
// Switch from "Browse Charts" to "My Playlists" tab
const browseTab = document.querySelector('.beatport-tab-button[data-beatport-tab="browse"]');
const playlistsTab = document.querySelector('.beatport-tab-button[data-beatport-tab="playlists"]');
const browseContent = document.getElementById('beatport-browse-content');
const playlistsContent = document.getElementById('beatport-playlists-content');
if (browseTab && playlistsTab && browseContent && playlistsContent) {
// Update tab buttons
browseTab.classList.remove('active');
playlistsTab.classList.add('active');
// Update tab content
browseContent.classList.remove('active');
playlistsContent.classList.add('active');
console.log('π Switched to Beatport "My Playlists" tab');
}
}
// ===============================
// BEATPORT SYNC FUNCTIONALITY
// ===============================
async function startBeatportPlaylistSync(urlHash) {
try {
console.log('π§ Starting Beatport playlist sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_beatport_playlist) {
console.error('β Invalid Beatport playlist state for sync');
showToast('Invalid Beatport playlist state', 'error');
return;
}
// Call Beatport sync endpoint
const response = await fetch(`/api/beatport/sync/start/${urlHash}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error starting sync: ${result.error}`, 'error');
return;
}
// Capture sync_playlist_id for WebSocket subscription (Beatport returns sync_id)
const syncPlaylistId = result.sync_id || result.sync_playlist_id;
if (state) state.syncPlaylistId = syncPlaylistId;
// Update state to syncing
state.phase = 'syncing';
updateBeatportCardPhase(state.beatport_chart_hash || urlHash, 'syncing');
// Update modal buttons and start polling
updateBeatportModalButtons(urlHash, 'syncing');
startBeatportSyncPolling(urlHash, syncPlaylistId);
showToast('Starting Beatport playlist sync...', 'success');
} catch (error) {
console.error('β Error starting Beatport sync:', error);
showToast(`Error starting sync: ${error.message}`, 'error');
}
}
function startBeatportSyncPolling(urlHash, syncPlaylistId) {
// Stop any existing polling (reuse activeYouTubePollers for Beatport)
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
// Resolve syncPlaylistId from argument or stored state
const bpState = youtubePlaylistStates[urlHash];
syncPlaylistId = syncPlaylistId || (bpState && bpState.syncPlaylistId);
// Phase 6: Subscribe via WebSocket
if (socketConnected && syncPlaylistId) {
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
_syncProgressCallbacks[syncPlaylistId] = (data) => {
const progress = data.progress || {};
updateBeatportModalSyncProgress(urlHash, progress);
if (data.status === 'finished' || data.status === 'error' || data.status === 'cancelled') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
const state = youtubePlaylistStates[urlHash];
if (state) {
const chartHash = state.beatport_chart_hash || urlHash;
if (data.status === 'finished') {
state.phase = 'sync_complete';
updateBeatportCardPhase(chartHash, 'sync_complete');
updateBeatportModalButtons(urlHash, 'sync_complete');
if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'sync_complete';
} else {
state.phase = 'discovered';
updateBeatportCardPhase(chartHash, 'discovered');
if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'discovered';
}
}
}
};
}
// Define the polling function (HTTP fallback)
const pollFunction = async () => {
if (socketConnected) return; // Phase 6: WS handles updates
try {
const response = await fetch(`/api/beatport/sync/status/${urlHash}`);
const status = await response.json();
if (status.error) {
console.error('β Error polling Beatport sync:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
updateBeatportModalSyncProgress(urlHash, status.progress);
if (status.complete || status.status === 'error') {
const state = youtubePlaylistStates[urlHash];
if (state) {
const chartHash = state.beatport_chart_hash || urlHash;
if (status.complete) {
state.phase = 'sync_complete';
state.convertedSpotifyPlaylistId = status.converted_spotify_playlist_id;
updateBeatportCardPhase(chartHash, 'sync_complete');
updateBeatportModalButtons(urlHash, 'sync_complete');
if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'sync_complete';
} else {
state.phase = 'discovered';
updateBeatportCardPhase(chartHash, 'discovered');
if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'discovered';
}
}
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
}
} catch (error) {
console.error('β Error polling Beatport sync:', error);
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
}
};
// Run immediately to get current status (skip if WS active)
if (!socketConnected) pollFunction();
// Then continue polling at regular intervals
const pollInterval = setInterval(pollFunction, 2000);
activeYouTubePollers[urlHash] = pollInterval;
}
async function cancelBeatportSync(urlHash) {
try {
console.log('β Cancelling Beatport sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_beatport_playlist) {
console.error('β Invalid Beatport playlist state');
return;
}
const response = await fetch(`/api/beatport/sync/cancel/${urlHash}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error cancelling sync: ${result.error}`, 'error');
return;
}
// Stop polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Phase 6: Clean up WS subscription
const bpSyncId = state && state.syncPlaylistId;
if (bpSyncId && _syncProgressCallbacks[bpSyncId]) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [bpSyncId] });
delete _syncProgressCallbacks[bpSyncId];
}
// Revert to discovered phase
const chartHash = state.beatport_chart_hash || urlHash;
state.phase = 'discovered';
updateBeatportCardPhase(chartHash, 'discovered');
updateBeatportModalButtons(urlHash, 'discovered');
// Sync with backend Beatport chart state
if (beatportChartStates[chartHash]) {
beatportChartStates[chartHash].phase = 'discovered';
}
showToast('Beatport sync cancelled', 'info');
} catch (error) {
console.error('β Error cancelling Beatport sync:', error);
showToast(`Error cancelling sync: ${error.message}`, 'error');
}
}
function updateBeatportModalSyncProgress(urlHash, progress) {
const statusDisplay = document.getElementById(`beatport-sync-status-${urlHash}`);
if (!statusDisplay || !progress) return;
console.log(`π Updating Beatport modal sync progress for ${urlHash}:`, progress);
// Update individual counters with Beatport-specific IDs
const totalEl = document.getElementById(`beatport-total-${urlHash}`);
const matchedEl = document.getElementById(`beatport-matched-${urlHash}`);
const failedEl = document.getElementById(`beatport-failed-${urlHash}`);
const percentageEl = document.getElementById(`beatport-percentage-${urlHash}`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const percentage = total > 0 ? Math.round((matched / total) * 100) : 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
if (percentageEl) percentageEl.textContent = percentage;
}
function updateBeatportModalButtons(urlHash, phase) {
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (!modal) return;
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
}
}
async function startBeatportDownloadMissing(urlHash) {
try {
console.log('π Starting download missing tracks for Beatport chart:', urlHash);
const state = youtubePlaylistStates[urlHash];
// Support both camelCase and snake_case
const discoveryResults = state?.discoveryResults || state?.discovery_results;
if (!state || !discoveryResults) {
showToast('No discovery results available for download', 'error');
return;
}
if (!state.is_beatport_playlist) {
console.error('β State is not a Beatport playlist');
showToast('Invalid Beatport chart state', 'error');
return;
}
// Convert Beatport discovery results to Spotify tracks format (like Tidal does)
console.log(`π Total discovery results: ${discoveryResults.length}`);
console.log(`π First result (full object):`, JSON.stringify(discoveryResults[0], null, 2));
console.log(`π Second result (full object):`, JSON.stringify(discoveryResults[1], null, 2));
console.log(`π Results with spotify_data:`, discoveryResults.filter(r => r.spotify_data).length);
console.log(`π Results with spotify_id:`, discoveryResults.filter(r => r.spotify_id).length);
const spotifyTracks = discoveryResults
.filter(result => {
// Accept if has spotify_data OR if has spotify_track (from automatic discovery)
return result.spotify_data || (result.spotify_track && result.status_class === 'found');
})
.map(result => {
// Use spotify_data if available, otherwise build from individual fields
let track;
if (result.spotify_data) {
track = result.spotify_data;
} else {
// Build from individual fields (automatic discovery format)
// Convert album to proper object format for wishlist compatibility
const albumData = result.spotify_album || 'Unknown Album';
const albumObject = typeof albumData === 'object' && albumData !== null
? albumData
: {
name: typeof albumData === 'string' ? albumData : 'Unknown Album',
album_type: 'album',
images: []
};
track = {
id: result.spotify_id || 'unknown',
name: result.spotify_track || 'Unknown Track',
artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'],
album: albumObject,
duration_ms: 0
};
}
// 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'];
}
// Ensure album is an object (in case it was converted back to string somehow)
const albumForReturn = typeof track.album === 'object' && track.album !== null
? track.album
: {
name: typeof track.album === 'string' ? track.album : 'Unknown Album',
album_type: 'album',
images: []
};
return {
id: track.id,
name: track.name,
artists: track.artists,
album: albumForReturn,
duration_ms: track.duration_ms || 0,
external_urls: track.external_urls || {}
};
});
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
console.log(`π§ Found ${spotifyTracks.length} Spotify tracks for Beatport download`);
// Create a virtual playlist for the download system
const virtualPlaylistId = `beatport_${urlHash}`;
const playlistName = state.playlist.name;
// Store reference for card navigation (but don't change phase yet)
state.convertedSpotifyPlaylistId = virtualPlaylistId;
// Store converted playlist ID in backend but keep current phase
const chartHash = state.beatport_chart_hash || urlHash;
if (beatportChartStates[chartHash]) {
try {
await fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phase: state.phase, // Keep current phase (should be 'discovered')
converted_spotify_playlist_id: virtualPlaylistId
})
});
console.log('β
Updated backend with Beatport converted playlist ID (phase unchanged)');
} catch (error) {
console.warn('β οΈ Error updating backend Beatport state:', error);
}
}
// Close the discovery modal if it's open
const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (discoveryModal) {
discoveryModal.classList.add('hidden');
console.log('π Closed Beatport discovery modal to show download modal');
}
// DON'T update card phase here - let the download modal handle phase changes when "Begin Analysis" is clicked
// Open download missing tracks modal using the same system as YouTube/Tidal
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
console.log(`β
Opened download modal for Beatport chart: ${state.playlist.name}`);
} catch (error) {
console.error('β Error starting Beatport download missing tracks:', error);
showToast(`Error starting downloads: ${error.message}`, 'error');
}
}
async function handleBeatportChartClick(chartType, chartId, chartName, chartEndpoint) {
console.log(`π΅ Beatport chart clicked: ${chartType} - ${chartId} - ${chartName}`);
try {
showToast(`Loading ${chartName}...`, 'info');
showLoadingOverlay(`Loading ${chartName}...`);
const response = await fetch(`${chartEndpoint}?limit=100`);
if (!response.ok) {
throw new Error(`Failed to fetch ${chartName}: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error(`No tracks found in ${chartName}`);
}
console.log(`β
Fetched ${data.tracks.length} tracks from ${chartName}`);
hideLoadingOverlay();
openBeatportChartAsDownloadModal(data.tracks, chartName, null);
} catch (error) {
console.error(`β Error handling Beatport chart click:`, error);
hideLoadingOverlay();
showToast(`Error loading ${chartName || chartId}: ${error.message}`, 'error');
}
}
function handleBeatportGenreClick(genreSlug, genreId, genreName) {
console.log(`π΅ Beatport genre clicked: ${genreName} (${genreSlug}/${genreId}) - SHOWING GENRE DETAIL VIEW`);
console.log(`π Debug: Parameters received - Slug: ${genreSlug}, ID: ${genreId}, Name: ${genreName}`);
// Navigate to genre detail view with proper parameters
showBeatportGenreDetailView(genreSlug, genreId, genreName);
}
function showBeatportGenreDetailView(genreSlug, genreId, genreName) {
console.log(`π― Showing genre detail view for: ${genreName}`);
console.log(`π Debug: Function called with - Slug: ${genreSlug}, ID: ${genreId}, Name: ${genreName}`);
// Hide all other beatport views
document.querySelectorAll('.beatport-sub-view').forEach(view => {
view.classList.remove('active');
});
const mainView = document.getElementById('beatport-main-view');
if (mainView) {
mainView.classList.remove('active');
}
// Show genre detail view
const genreDetailView = document.getElementById('beatport-genre-detail-view');
if (genreDetailView) {
genreDetailView.classList.add('active');
console.log(`π Debug: Genre detail view element found and activated`);
// Update view content
const titleElement = document.getElementById('genre-detail-title');
const breadcrumbElement = document.getElementById('genre-detail-breadcrumb');
console.log(`π Debug: Title element found: ${!!titleElement}, Breadcrumb element found: ${!!breadcrumbElement}`);
if (titleElement) {
titleElement.textContent = genreName;
console.log(`π Debug: Updated title to: ${genreName}`);
}
if (breadcrumbElement) {
breadcrumbElement.textContent = `Browse Charts > Genre Explorer > ${genreName} Charts`;
console.log(`π Debug: Updated breadcrumb`);
}
// Update chart type titles with genre name
const chartTitles = [
'genre-top-10-title',
'genre-top-100-title',
'genre-releases-top-10-title',
'genre-releases-top-100-title',
'genre-staff-picks-title',
'genre-latest-releases-title',
'genre-new-charts-title'
];
chartTitles.forEach(titleId => {
const element = document.getElementById(titleId);
if (element) {
console.log(`π Debug: Found chart title element: ${titleId}`);
} else {
console.log(`π Debug: Missing chart title element: ${titleId}`);
}
});
document.getElementById('genre-top-10-title').textContent = `Top 10 ${genreName}`;
document.getElementById('genre-top-100-title').textContent = `Top 100 ${genreName}`;
document.getElementById('genre-releases-top-10-title').textContent = `Top 10 ${genreName} Releases`;
document.getElementById('genre-releases-top-100-title').textContent = `Top 100 ${genreName} Releases`;
document.getElementById('genre-staff-picks-title').textContent = `${genreName} Staff Picks`;
document.getElementById('genre-latest-releases-title').textContent = `Latest ${genreName} Releases`;
// Update Hype section titles
document.getElementById('genre-hype-top-10-title').textContent = `${genreName} Hype Top 10`;
document.getElementById('genre-hype-top-100-title').textContent = `${genreName} Hype Top 100`;
document.getElementById('genre-hype-picks-title').textContent = `${genreName} Hype Picks`;
// Load new charts directly (no expansion needed)
console.log(`π Auto-loading new charts for ${genreName}...`);
loadNewChartsInline(genreSlug, genreId, genreName);
// Store current genre data for chart type handlers
genreDetailView.dataset.genreSlug = genreSlug;
genreDetailView.dataset.genreId = genreId;
genreDetailView.dataset.genreName = genreName;
// Add click handlers to chart type cards
setupGenreChartTypeHandlers();
console.log(`β
Genre detail view shown for ${genreName}`);
} else {
console.error('β Genre detail view element not found');
}
}
function setupGenreChartTypeHandlers() {
const chartTypeCards = document.querySelectorAll('#beatport-genre-detail-view .genre-chart-type-card');
chartTypeCards.forEach(card => {
// Remove existing listeners
card.replaceWith(card.cloneNode(true));
});
// Re-select after cloning
const newChartTypeCards = document.querySelectorAll('#beatport-genre-detail-view .genre-chart-type-card');
newChartTypeCards.forEach(card => {
card.addEventListener('click', () => {
const chartType = card.dataset.chartType;
const genreDetailView = document.getElementById('beatport-genre-detail-view');
const genreSlug = genreDetailView.dataset.genreSlug;
const genreId = genreDetailView.dataset.genreId;
const genreName = genreDetailView.dataset.genreName;
// All chart types now go directly to discovery modal
handleGenreChartTypeClick(genreSlug, genreId, genreName, chartType);
});
});
}
function showBeatportGenresView() {
// Hide genre detail view and show genres view
document.querySelectorAll('.beatport-sub-view').forEach(view => {
view.classList.remove('active');
});
const genresView = document.getElementById('beatport-genres-view');
if (genresView) {
genresView.classList.add('active');
}
}
async function toggleNewChartsExpansion(genreSlug, genreId, genreName) {
console.log(`π Toggling new charts expansion for: ${genreName}`);
const expandedContent = document.getElementById('new-charts-expanded');
const expandIndicator = document.getElementById('expand-indicator');
const chartsCount = document.getElementById('new-charts-count');
if (!expandedContent || !expandIndicator) {
console.error('β New charts expansion elements not found');
return;
}
// Check if already expanded
const isExpanded = expandedContent.style.display !== 'none';
if (isExpanded) {
// Collapse
expandedContent.style.display = 'none';
expandIndicator.classList.remove('expanded');
console.log('π Collapsed new charts section');
} else {
// Expand and load charts
expandedContent.style.display = 'block';
expandIndicator.classList.add('expanded');
// Load charts if not already loaded
await loadNewChartsInline(genreSlug, genreId, genreName);
console.log('π Expanded new charts section');
}
}
async function loadNewChartsInline(genreSlug, genreId, genreName) {
const chartsGrid = document.getElementById('new-charts-grid');
const loadingInline = document.getElementById('charts-loading-inline');
if (!chartsGrid || !loadingInline) {
console.error('β Inline charts elements not found');
return;
}
// Show loading state
loadingInline.style.display = 'block';
chartsGrid.style.display = 'none';
chartsGrid.innerHTML = '';
try {
console.log(`π Loading inline charts for ${genreName}...`);
// Fetch charts from the new-charts endpoint
const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/new-charts?limit=20`);
if (!response.ok) {
throw new Error(`Failed to fetch charts: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
// Show empty state
chartsGrid.innerHTML = `
No Charts Available
No curated charts found for ${genreName} at the moment.
`;
} else {
// Populate charts grid
const chartsHTML = data.tracks.map((chart, index) => {
const chartName = chart.title || 'Untitled Chart';
const artistName = chart.artist || 'Various Artists';
const chartUrl = chart.url || '';
return `
Curated ${genreName} chart collection
`;
}).join('');
chartsGrid.innerHTML = chartsHTML;
// Add click handlers to chart items
setupNewChartItemHandlers(genreSlug, genreId, genreName);
}
// Hide loading and show grid
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
console.log(`β
Loaded ${data.tracks?.length || 0} inline charts for ${genreName}`);
showToast(`Found ${data.tracks?.length || 0} chart collections`, 'success');
} catch (error) {
console.error(`β Error loading inline charts for ${genreName}:`, error);
// Show error state
chartsGrid.innerHTML = `
Error Loading Charts
Unable to load chart collections for ${genreName}.
`;
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
showToast(`Error loading charts: ${error.message}`, 'error');
}
}
async function loadDJChartsInline() {
const chartsGrid = document.getElementById('dj-charts-grid');
const loadingInline = document.getElementById('dj-charts-loading-inline');
if (!chartsGrid || !loadingInline) {
console.error('β DJ charts elements not found');
return;
}
// Show loading state
loadingInline.style.display = 'block';
chartsGrid.style.display = 'none';
chartsGrid.innerHTML = '';
try {
console.log('π Loading DJ charts...');
// Fetch charts from the dj-charts-improved endpoint
const response = await fetch('/api/beatport/dj-charts-improved?limit=20');
if (!response.ok) {
throw new Error(`Failed to fetch DJ charts: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.charts || data.charts.length === 0) {
// Show empty state
chartsGrid.innerHTML = `
No DJ Charts Available
No DJ curated charts found at the moment.
`;
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
return;
}
// Create chart items using New Charts structure
const chartsHTML = data.charts.map(chart => {
const chartName = chart.name || chart.title || 'Untitled Chart';
const artistName = chart.artist || chart.curator || 'Various Artists';
const chartUrl = chart.url || chart.chart_url || '';
return `
DJ curated chart collection
`;
}).join('');
chartsGrid.innerHTML = chartsHTML;
// Hide loading, show content
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
// Setup click handlers for chart items
setupDJChartItemHandlers();
console.log(`β
Loaded ${data.charts.length} DJ charts`);
} catch (error) {
console.error('β Error loading DJ charts:', error);
// Show error state
chartsGrid.innerHTML = `
Error Loading DJ Charts
Unable to load DJ chart collections.
`;
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
showToast(`Error loading DJ charts: ${error.message}`, 'error');
}
}
async function loadFeaturedChartsInline() {
const chartsGrid = document.getElementById('featured-charts-grid');
const loadingInline = document.getElementById('featured-charts-loading-inline');
if (!chartsGrid || !loadingInline) {
console.error('β Featured charts elements not found');
return;
}
// Show loading state
loadingInline.style.display = 'block';
chartsGrid.style.display = 'none';
chartsGrid.innerHTML = '';
try {
console.log('π Loading Featured charts...');
// Fetch charts from the homepage/featured-charts endpoint
const response = await fetch('/api/beatport/homepage/featured-charts?limit=20');
if (!response.ok) {
throw new Error(`Failed to fetch Featured charts: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
// Show empty state
chartsGrid.innerHTML = `
No Featured Charts Available
No featured curated charts found at the moment.
`;
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
return;
}
// Create chart items using New Charts structure
const chartsHTML = data.tracks.map(chart => {
const chartName = chart.name || chart.title || 'Untitled Chart';
const artistName = chart.artist || chart.curator || 'Various Artists';
const chartUrl = chart.url || chart.chart_url || '';
return `
Editor curated chart collection
`;
}).join('');
chartsGrid.innerHTML = chartsHTML;
// Hide loading, show content
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
// Setup click handlers for chart items
setupFeaturedChartItemHandlers();
console.log(`β
Loaded ${data.tracks.length} Featured charts`);
} catch (error) {
console.error('β Error loading Featured charts:', error);
// Show error state
chartsGrid.innerHTML = `
Error Loading Featured Charts
Unable to load featured chart collections.
`;
loadingInline.style.display = 'none';
chartsGrid.style.display = 'grid';
showToast(`Error loading Featured charts: ${error.message}`, 'error');
}
}
function setupDJChartItemHandlers() {
const chartItems = document.querySelectorAll('#dj-charts-grid .new-chart-item');
chartItems.forEach(item => {
item.addEventListener('click', async () => {
const chartName = item.dataset.chartName;
const chartUrl = item.dataset.chartUrl;
console.log(`π§ DJ Chart clicked: ${chartName}`);
try {
showToast(`Loading ${chartName}...`, 'info');
showLoadingOverlay(`Scraping ${chartName}...`);
const response = await fetch('/api/beatport/chart/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chart_url: chartUrl, chart_name: chartName, limit: 100, enrich: false })
});
if (!response.ok) {
throw new Error(`Failed to extract chart tracks: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found in chart');
}
console.log(`β
Extracted ${data.tracks.length} raw tracks from DJ chart, enriching...`);
const enrichedTracks = await _enrichTracksWithProgress(data.tracks, chartName);
hideLoadingOverlay();
openBeatportChartAsDownloadModal(enrichedTracks, chartName, null);
} catch (error) {
console.error('β Error extracting DJ chart tracks:', error);
hideLoadingOverlay();
showToast(`Error loading chart: ${error.message}`, 'error');
}
});
});
}
function setupFeaturedChartItemHandlers() {
const chartItems = document.querySelectorAll('#featured-charts-grid .new-chart-item');
chartItems.forEach(item => {
item.addEventListener('click', async () => {
const chartName = item.dataset.chartName;
const chartUrl = item.dataset.chartUrl;
console.log(`β Featured Chart clicked: ${chartName}`);
try {
showToast(`Loading ${chartName}...`, 'info');
showLoadingOverlay(`Scraping ${chartName}...`);
const response = await fetch('/api/beatport/chart/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chart_url: chartUrl, chart_name: chartName, limit: 100, enrich: false })
});
if (!response.ok) {
throw new Error(`Failed to extract chart tracks: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found in chart');
}
console.log(`β
Extracted ${data.tracks.length} raw tracks from Featured chart, enriching...`);
const enrichedTracks = await _enrichTracksWithProgress(data.tracks, chartName);
hideLoadingOverlay();
openBeatportChartAsDownloadModal(enrichedTracks, chartName, null);
} catch (error) {
console.error('β Error extracting Featured chart tracks:', error);
hideLoadingOverlay();
showToast(`Error loading chart: ${error.message}`, 'error');
}
});
});
}
function setupNewChartItemHandlers(genreSlug, genreId, genreName) {
const chartItems = document.querySelectorAll('#new-charts-grid .new-chart-item');
chartItems.forEach(item => {
item.addEventListener('click', async () => {
const chartName = item.dataset.chartName;
const chartArtist = item.dataset.chartArtist;
const chartUrl = item.dataset.chartUrl;
console.log(`π΅ Chart clicked: ${chartName} by ${chartArtist}`);
const fullChartName = `${chartName} (${genreName})`;
try {
showToast(`Loading ${chartName}...`, 'info');
showLoadingOverlay(`Scraping ${chartName}...`);
const response = await fetch('/api/beatport/chart/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chart_url: chartUrl, chart_name: chartName, limit: 100, enrich: false })
});
if (!response.ok) {
throw new Error(`Failed to fetch chart content: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found in chart');
}
console.log(`β
Extracted ${data.tracks.length} raw tracks from ${fullChartName}, enriching...`);
const enrichedTracks = await _enrichTracksWithProgress(data.tracks, fullChartName);
hideLoadingOverlay();
openBeatportChartAsDownloadModal(enrichedTracks, fullChartName, null);
} catch (error) {
console.error(`β Error loading chart: ${error.message}`);
hideLoadingOverlay();
showToast(`Error loading chart: ${error.message}`, 'error');
}
});
});
}
function showBeatportGenreDetailViewFromBack() {
// Show genre detail view (used by charts list back button)
document.querySelectorAll('.beatport-sub-view').forEach(view => {
view.classList.remove('active');
});
const genreDetailView = document.getElementById('beatport-genre-detail-view');
if (genreDetailView) {
genreDetailView.classList.add('active');
}
}
async function showBeatportGenreChartsListView(genreSlug, genreId, genreName) {
console.log(`π Showing charts list for: ${genreName}`);
// Hide all other beatport views
document.querySelectorAll('.beatport-sub-view').forEach(view => {
view.classList.remove('active');
});
const mainView = document.getElementById('beatport-main-view');
if (mainView) {
mainView.classList.remove('active');
}
// Show charts list view
const chartsListView = document.getElementById('beatport-genre-charts-list-view');
if (chartsListView) {
chartsListView.classList.add('active');
// Update view content
document.getElementById('genre-charts-list-title').textContent = `New ${genreName} Charts`;
document.getElementById('genre-charts-list-breadcrumb').textContent = `Browse Charts > Genre Explorer > ${genreName} Charts > New Charts`;
// Store current genre data for individual chart handlers
chartsListView.dataset.genreSlug = genreSlug;
chartsListView.dataset.genreId = genreId;
chartsListView.dataset.genreName = genreName;
// Load charts for this genre
await loadGenreChartsList(genreSlug, genreId, genreName);
console.log(`β
Charts list view shown for ${genreName}`);
} else {
console.error('β Charts list view element not found');
}
}
async function loadGenreChartsList(genreSlug, genreId, genreName) {
const chartsGrid = document.getElementById('genre-charts-grid');
const loadingPlaceholder = document.getElementById('charts-loading-placeholder');
if (!chartsGrid || !loadingPlaceholder) {
console.error('β Charts grid or loading placeholder not found');
return;
}
// Show loading state
loadingPlaceholder.style.display = 'block';
chartsGrid.style.display = 'none';
chartsGrid.innerHTML = '';
try {
console.log(`π Loading charts for ${genreName}...`);
// Fetch charts from the new-charts endpoint
const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/new-charts?limit=50`);
if (!response.ok) {
throw new Error(`Failed to fetch charts: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
// Show empty state
chartsGrid.innerHTML = `
No Charts Available
No curated charts found for ${genreName} at the moment. Check back later for new DJ and artist chart collections.
`;
} else {
// Populate charts grid
const chartsHTML = data.tracks.map((chart, index) => {
const chartName = chart.title || 'Untitled Chart';
const artistName = chart.artist || 'Various Artists';
const chartUrl = chart.url || '';
// Extract chart ID from URL for click handling
const chartId = chartUrl.split('/').pop() || `chart_${index}`;
return `
Curated chart collection featuring ${genreName} tracks
`;
}).join('');
chartsGrid.innerHTML = chartsHTML;
// Add click handlers to chart items
setupGenreChartItemHandlers(genreSlug, genreId, genreName);
}
// Hide loading and show grid
loadingPlaceholder.style.display = 'none';
chartsGrid.style.display = 'grid';
console.log(`β
Loaded ${data.tracks?.length || 0} charts for ${genreName}`);
showToast(`Found ${data.tracks?.length || 0} chart collections`, 'success');
} catch (error) {
console.error(`β Error loading charts for ${genreName}:`, error);
// Show error state
chartsGrid.innerHTML = `
Error Loading Charts
Unable to load chart collections for ${genreName}. Please try again later.
`;
loadingPlaceholder.style.display = 'none';
chartsGrid.style.display = 'grid';
showToast(`Error loading charts: ${error.message}`, 'error');
}
}
function setupGenreChartItemHandlers(genreSlug, genreId, genreName) {
const chartItems = document.querySelectorAll('#genre-charts-grid .genre-chart-item');
chartItems.forEach(item => {
item.addEventListener('click', async () => {
const chartName = item.dataset.chartName;
const chartArtist = item.dataset.chartArtist;
const chartUrl = item.dataset.chartUrl;
console.log(`π΅ Chart clicked: ${chartName} by ${chartArtist}`);
const fullChartName = `${chartName} (${genreName})`;
try {
showToast(`Loading ${chartName}...`, 'info');
showLoadingOverlay(`Scraping ${chartName}...`);
const response = await fetch('/api/beatport/chart/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chart_url: chartUrl, chart_name: chartName, limit: 100, enrich: false })
});
if (!response.ok) {
throw new Error(`Failed to fetch chart content: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found in chart');
}
console.log(`β
Extracted ${data.tracks.length} raw tracks from ${fullChartName}, enriching...`);
const enrichedTracks = await _enrichTracksWithProgress(data.tracks, fullChartName);
hideLoadingOverlay();
openBeatportChartAsDownloadModal(enrichedTracks, fullChartName, null);
} catch (error) {
console.error(`β Error loading chart: ${error.message}`);
hideLoadingOverlay();
showToast(`Error loading chart: ${error.message}`, 'error');
}
});
});
}
async function handleGenreChartTypeClick(genreSlug, genreId, genreName, chartType) {
console.log(`π― Genre chart type clicked: ${chartType} for ${genreName} (${genreSlug}/${genreId})`);
// Map chart types to API endpoints and create descriptive names
const chartTypeMap = {
'top-10': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/top-10`,
name: `Top 10 ${genreName}`,
limit: 10
},
'top-100': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/tracks`,
name: `Top 100 ${genreName}`,
limit: 100
},
'releases-top-10': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/releases-top-10`,
name: `Top 10 ${genreName} Releases`,
limit: 10
},
'releases-top-100': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/releases-top-100`,
name: `Top 100 ${genreName} Releases`,
limit: 100
},
'staff-picks': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/staff-picks`,
name: `${genreName} Staff Picks`,
limit: 50
},
'latest-releases': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/latest-releases`,
name: `Latest ${genreName} Releases`,
limit: 50
},
'hype-top-10': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/hype-top-10`,
name: `${genreName} Hype Top 10`,
limit: 10
},
'hype-top-100': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/hype-top-100`,
name: `${genreName} Hype Top 100`,
limit: 100
},
'hype-picks': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/hype-picks`,
name: `${genreName} Hype Picks`,
limit: 50
},
'new-charts': {
endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/new-charts`,
name: `New ${genreName} Charts`,
limit: 100
}
};
const chartConfig = chartTypeMap[chartType];
if (!chartConfig) {
console.error(`β Unknown chart type: ${chartType}`);
showToast(`Unknown chart type: ${chartType}`, 'error');
return;
}
try {
showToast(`Loading ${chartConfig.name}...`, 'info');
showLoadingOverlay(`Loading ${chartConfig.name}...`);
const response = await fetch(`${chartConfig.endpoint}?limit=${chartConfig.limit}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${chartConfig.name}: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error(`No tracks found in ${chartConfig.name}`);
}
console.log(`β
Fetched ${data.tracks.length} tracks from ${chartConfig.name}`);
hideLoadingOverlay();
openBeatportChartAsDownloadModal(data.tracks, chartConfig.name, null);
} catch (error) {
console.error(`β Error loading ${chartConfig.name}:`, error);
hideLoadingOverlay();
showToast(`Error loading ${chartConfig.name}: ${error.message}`, 'error');
}
}
// ===============================
// SPOTIFY PUBLIC LINK FUNCTIONALITY
// ===============================
let spotifyPublicPlaylists = []; // Array of loaded Spotify public playlist objects
let spotifyPublicPlaylistStates = {}; // Key: url_hash, Value: state dict
async function parseSpotifyPublicUrl() {
const urlInput = document.getElementById('spotify-public-url-input');
const url = urlInput.value.trim();
if (!url) {
showToast('Please enter a Spotify URL', 'error');
return;
}
// Basic URL validation
if (!url.includes('open.spotify.com/playlist') && !url.includes('open.spotify.com/album') &&
!url.startsWith('spotify:playlist:') && !url.startsWith('spotify:album:')) {
showToast('Please enter a valid Spotify playlist or album URL', 'error');
return;
}
// Check if already loaded
if (_isUrlAlreadyLoaded('spotify-public', url)) {
showToast('This playlist is already loaded', 'info');
urlInput.value = '';
return;
}
const parseBtn = document.getElementById('spotify-public-parse-btn');
if (parseBtn) {
parseBtn.disabled = true;
parseBtn.textContent = 'Loading...';
}
try {
console.log('π΅ Parsing public Spotify URL:', url);
const response = await fetch('/api/spotify/parse-public', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const result = await response.json();
if (result.error) {
showToast(`Error: ${result.error}`, 'error');
return;
}
// Check if already loaded
if (spotifyPublicPlaylists.find(p => String(p.url_hash) === String(result.url_hash))) {
showToast('This playlist is already loaded', 'info');
urlInput.value = '';
return;
}
console.log(`β
Spotify ${result.type} parsed: ${result.name} (${result.track_count} tracks)`);
spotifyPublicPlaylists.push(result);
// Auto-mirror
if (result.tracks && result.tracks.length > 0) {
mirrorPlaylist('spotify_public', result.url_hash, result.name, result.tracks.map(t => ({
track_name: t.name || '',
artist_name: Array.isArray(t.artists) ? t.artists.map(a => a.name).join(', ') : '',
album_name: t.album?.name || '',
duration_ms: t.duration_ms || 0,
source_track_id: t.id || ''
})), { owner: result.subtitle || '', image_url: '', description: result.url || '' });
}
// Save to URL history
saveUrlHistory('spotify-public', url, result.name);
renderSpotifyPublicPlaylists();
await loadSpotifyPublicPlaylistStatesFromBackend();
urlInput.value = '';
showToast(`Loaded: ${result.name} (${result.track_count} tracks)`, 'success');
console.log(`π΅ Loaded Spotify playlist: ${result.name}`);
} catch (error) {
console.error('β Error parsing Spotify URL:', error);
showToast(`Error parsing Spotify URL: ${error.message}`, 'error');
} finally {
if (parseBtn) {
parseBtn.disabled = false;
parseBtn.textContent = 'Load';
}
}
}
function renderSpotifyPublicPlaylists() {
const container = document.getElementById('spotify-public-playlist-container');
if (spotifyPublicPlaylists.length === 0) {
container.innerHTML = `Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.
`;
return;
}
container.innerHTML = spotifyPublicPlaylists.map(p => {
if (!spotifyPublicPlaylistStates[p.url_hash]) {
spotifyPublicPlaylistStates[p.url_hash] = {
phase: 'fresh',
playlist: p
};
}
return createSpotifyPublicCard(p);
}).join('');
// Add click handlers to cards
spotifyPublicPlaylists.forEach(p => {
const card = document.getElementById(`spotify-public-card-${p.url_hash}`);
if (card) {
card.addEventListener('click', () => handleSpotifyPublicCardClick(p.url_hash));
}
});
}
function createSpotifyPublicCard(playlist) {
const state = spotifyPublicPlaylistStates[playlist.url_hash];
const phase = state ? state.phase : 'fresh';
const isAlbum = playlist.type === 'album';
let buttonText = getActionButtonText(phase);
let phaseText = getPhaseText(phase);
let phaseColor = getPhaseColor(phase);
return `
${isAlbum ? 'πΏ' : 'π΅'}
${escapeHtml(playlist.name)}
${isAlbum ? 'Album' : 'Playlist'}
${playlist.track_count || playlist.tracks.length} tracks
${phaseText}
${buttonText}
`;
}
async function handleSpotifyPublicCardClick(urlHash) {
const state = spotifyPublicPlaylistStates[urlHash];
if (!state) {
console.error(`No state found for Spotify public playlist: ${urlHash}`);
showToast('Playlist state not found - try refreshing the page', 'error');
return;
}
if (!state.playlist) {
console.error(`No playlist data found for Spotify public playlist: ${urlHash}`);
showToast('Playlist data missing - try refreshing the page', 'error');
return;
}
if (!state.phase) {
state.phase = 'fresh';
}
console.log(`π΅ [Card Click] Spotify public card clicked: ${urlHash}, Phase: ${state.phase}`);
if (state.phase === 'fresh') {
console.log(`π΅ Using pre-loaded Spotify public playlist data for: ${state.playlist.name}`);
openSpotifyPublicDiscoveryModal(urlHash, state.playlist);
} else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') {
console.log(`π΅ [Card Click] Opening Spotify public discovery modal for ${state.phase} phase`);
if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) {
try {
const stateResponse = await fetch(`/api/spotify-public/state/${urlHash}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
if (fullState.discovery_results) {
state.discovery_results = fullState.discovery_results;
state.spotify_matches = fullState.spotify_matches || state.spotify_matches;
state.discovery_progress = fullState.discovery_progress || state.discovery_progress;
spotifyPublicPlaylistStates[urlHash] = { ...spotifyPublicPlaylistStates[urlHash], ...state };
console.log(`Restored ${fullState.discovery_results.length} discovery results from backend`);
}
}
} catch (error) {
console.error(`Failed to fetch discovery results from backend: ${error}`);
}
}
openSpotifyPublicDiscoveryModal(urlHash, state.playlist);
} else if (state.phase === 'downloading' || state.phase === 'download_complete') {
if (state.convertedSpotifyPlaylistId) {
if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) {
const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId];
if (process.modalElement) {
process.modalElement.style.display = 'flex';
} else {
await rehydrateSpotifyPublicDownloadModal(urlHash, state);
}
} else {
await rehydrateSpotifyPublicDownloadModal(urlHash, state);
}
} else {
if (state.discovery_results && state.discovery_results.length > 0) {
openSpotifyPublicDiscoveryModal(urlHash, state.playlist);
} else {
showToast('Unable to open download modal - missing playlist data', 'error');
}
}
}
}
async function rehydrateSpotifyPublicDownloadModal(urlHash, state) {
try {
if (!state || !state.playlist) {
showToast('Cannot open download modal - invalid playlist data', 'error');
return;
}
const spotifyTracks = state.discovery_results
?.filter(result => result.spotify_data)
?.map(result => result.spotify_data) || [];
if (spotifyTracks.length > 0) {
const virtualPlaylistId = state.convertedSpotifyPlaylistId || `spotify_public_${urlHash}`;
await openDownloadMissingModalForTidal(virtualPlaylistId, state.playlist.name, spotifyTracks);
if (state.download_process_id) {
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = 'running';
process.batchId = state.download_process_id;
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';
startModalDownloadPolling(virtualPlaylistId);
}
}
} else {
showToast('No Spotify tracks found for download', 'error');
}
} catch (error) {
console.error(`Error rehydrating Spotify public download modal: ${error}`);
}
}
async function openSpotifyPublicDiscoveryModal(urlHash, playlistData) {
console.log(`π΅ Opening Spotify public discovery modal (reusing YouTube modal): ${playlistData.name}`);
const fakeUrlHash = `spotifypublic_${urlHash}`;
const cardState = spotifyPublicPlaylistStates[urlHash];
const isAlreadyDiscovered = cardState && (cardState.phase === 'discovered' || cardState.phase === 'syncing' || cardState.phase === 'sync_complete');
const isCurrentlyDiscovering = cardState && cardState.phase === 'discovering';
let transformedResults = [];
let actualMatches = 0;
if (isAlreadyDiscovered && cardState.discovery_results) {
transformedResults = cardState.discovery_results.map((result, index) => {
const isFound = result.status === 'found' ||
result.status === 'β
Found' ||
result.status_class === 'found' ||
result.spotify_data ||
result.spotify_track;
if (isFound) actualMatches++;
return {
index: index,
yt_track: result.spotify_public_track ? result.spotify_public_track.name : 'Unknown',
yt_artist: result.spotify_public_track ? (result.spotify_public_track.artists ? result.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? 'β
Found' : 'β Not Found',
status_class: isFound ? 'found' : '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)
? result.spotify_data.artists
.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a)
.filter(Boolean)
.join(', ') || '-'
: 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,
spotify_id: result.spotify_id,
manual_match: result.manual_match
};
});
console.log(`π΅ Spotify public modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`);
}
// Normalize artist objects to strings for the discovery modal table
const normalizedTracks = playlistData.tracks.map(t => ({
...t,
artists: Array.isArray(t.artists)
? t.artists.map(a => typeof a === 'object' ? a.name : a)
: t.artists
}));
const modalPhase = cardState ? cardState.phase : 'fresh';
youtubePlaylistStates[fakeUrlHash] = {
phase: modalPhase,
playlist: {
name: playlistData.name,
tracks: normalizedTracks
},
is_spotify_public_playlist: true,
spotify_public_playlist_id: urlHash,
discovery_progress: isAlreadyDiscovered ? 100 : 0,
spotify_matches: isAlreadyDiscovered ? actualMatches : 0,
spotifyMatches: isAlreadyDiscovered ? actualMatches : 0,
spotify_total: playlistData.tracks.length,
discovery_results: transformedResults,
discoveryResults: transformedResults,
discoveryProgress: isAlreadyDiscovered ? 100 : 0
};
if (!isAlreadyDiscovered && !isCurrentlyDiscovering) {
try {
console.log(`π Starting Spotify public discovery for: ${playlistData.name}`);
const response = await fetch(`/api/spotify-public/discovery/start/${urlHash}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
console.error('Error starting Spotify public discovery:', result.error);
showToast(`Error starting discovery: ${result.error}`, 'error');
return;
}
console.log('Spotify public discovery started, beginning polling...');
spotifyPublicPlaylistStates[urlHash].phase = 'discovering';
updateSpotifyPublicCardPhase(urlHash, 'discovering');
youtubePlaylistStates[fakeUrlHash].phase = 'discovering';
startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash);
} catch (error) {
console.error('Error starting Spotify public discovery:', error);
showToast(`Error starting discovery: ${error.message}`, 'error');
}
} else if (isCurrentlyDiscovering) {
console.log(`π Resuming Spotify public discovery polling for: ${playlistData.name}`);
startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash);
} else if (cardState && cardState.phase === 'syncing') {
console.log(`π Resuming Spotify public sync polling for: ${playlistData.name}`);
startSpotifyPublicSyncPolling(fakeUrlHash);
} else {
console.log('Using existing results - no need to re-discover');
}
openYouTubeDiscoveryModal(fakeUrlHash);
}
function startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash) {
console.log(`π Starting Spotify public discovery polling for: ${urlHash}`);
if (activeYouTubePollers[fakeUrlHash]) {
clearInterval(activeYouTubePollers[fakeUrlHash]);
}
// WebSocket subscription
if (socketConnected) {
socket.emit('discovery:subscribe', { ids: [urlHash] });
_discoveryProgressCallbacks[urlHash] = (data) => {
if (data.error) {
if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; }
socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash];
return;
}
const transformed = {
progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total,
complete: data.complete,
results: (data.results || []).map((r, i) => {
const isWingIt = r.wing_it_fallback || r.status_class === 'wing-it';
const isFound = !isWingIt && (r.status === 'found' || r.status === 'β
Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track);
return {
index: i, yt_track: r.spotify_public_track ? r.spotify_public_track.name : 'Unknown',
yt_artist: r.spotify_public_track ? (r.spotify_public_track.artists ? r.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isWingIt ? 'π― Wing It' : (isFound ? 'β
Found' : 'β Not Found'),
status_class: isWingIt ? 'wing-it' : (isFound ? 'found' : 'not-found'),
spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'),
spotify_artist: r.spotify_data && r.spotify_data.artists
? (Array.isArray(r.spotify_data.artists)
? (r.spotify_data.artists
.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a)
.filter(Boolean)
.join(', ') || '-')
: r.spotify_data.artists)
: (r.spotify_artist || '-'),
spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'),
spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match,
wing_it_fallback: isWingIt
};
})
};
const st = youtubePlaylistStates[fakeUrlHash];
if (st) {
st.discovery_progress = data.progress; st.discoveryProgress = data.progress;
st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches;
st.discovery_results = data.results; st.discoveryResults = transformed.results;
st.phase = data.phase;
updateYouTubeDiscoveryModal(fakeUrlHash, transformed);
}
if (spotifyPublicPlaylistStates[urlHash]) {
spotifyPublicPlaylistStates[urlHash].phase = data.phase;
spotifyPublicPlaylistStates[urlHash].discovery_results = data.results;
spotifyPublicPlaylistStates[urlHash].spotify_matches = data.spotify_matches;
spotifyPublicPlaylistStates[urlHash].discovery_progress = data.progress;
updateSpotifyPublicCardPhase(urlHash, data.phase);
}
updateSpotifyPublicCardProgress(urlHash, data);
if (data.complete) {
if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; }
socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash];
}
};
}
const pollInterval = setInterval(async () => {
if (socketConnected) return;
try {
const response = await fetch(`/api/spotify-public/discovery/status/${urlHash}`);
const status = await response.json();
if (status.error) {
console.error('Error polling Spotify public discovery status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
return;
}
const transformedStatus = {
progress: status.progress,
spotify_matches: status.spotify_matches,
spotify_total: status.spotify_total,
complete: status.complete,
results: status.results.map((result, index) => {
const isFound = result.status === 'found' ||
result.status === 'β
Found' ||
result.status_class === 'found' ||
result.spotify_data ||
result.spotify_track;
return {
index: index,
yt_track: result.spotify_public_track ? result.spotify_public_track.name : 'Unknown',
yt_artist: result.spotify_public_track ? (result.spotify_public_track.artists ? result.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? 'β
Found' : 'β Not Found',
status_class: isFound ? 'found' : '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)
? (result.spotify_data.artists
.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a)
.filter(Boolean)
.join(', ') || '-')
: 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,
spotify_id: result.spotify_id,
manual_match: result.manual_match
};
})
};
const state = youtubePlaylistStates[fakeUrlHash];
if (state) {
state.discovery_progress = status.progress;
state.discoveryProgress = status.progress;
state.spotify_matches = status.spotify_matches;
state.spotifyMatches = status.spotify_matches;
state.discovery_results = status.results;
state.discoveryResults = transformedStatus.results;
state.phase = status.phase;
updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus);
if (spotifyPublicPlaylistStates[urlHash]) {
spotifyPublicPlaylistStates[urlHash].phase = status.phase;
spotifyPublicPlaylistStates[urlHash].discovery_results = status.results;
spotifyPublicPlaylistStates[urlHash].spotify_matches = status.spotify_matches;
spotifyPublicPlaylistStates[urlHash].discovery_progress = status.progress;
updateSpotifyPublicCardPhase(urlHash, status.phase);
}
updateSpotifyPublicCardProgress(urlHash, status);
console.log(`π Spotify public discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`);
}
if (status.complete) {
console.log(`Spotify public discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
}
} catch (error) {
console.error('Error polling Spotify public discovery:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[fakeUrlHash];
}
}, 1000);
activeYouTubePollers[fakeUrlHash] = pollInterval;
}
async function loadSpotifyPublicPlaylistStatesFromBackend() {
try {
console.log('π΅ Loading Spotify public playlist states from backend...');
const response = await fetch('/api/spotify-public/playlists/states');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch Spotify public playlist states');
}
const data = await response.json();
const states = data.states || [];
console.log(`π΅ Found ${states.length} stored Spotify public playlist states in backend`);
if (states.length === 0) return;
for (const stateInfo of states) {
await applySpotifyPublicPlaylistState(stateInfo);
}
// Rehydrate download modals for playlists in downloading/download_complete phases
for (const stateInfo of states) {
if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') &&
stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) {
const convertedPlaylistId = stateInfo.converted_spotify_playlist_id;
if (!activeDownloadProcesses[convertedPlaylistId]) {
console.log(`Rehydrating download modal for Spotify public playlist: ${stateInfo.playlist_id}`);
try {
const playlistData = spotifyPublicPlaylists.find(p => String(p.url_hash) === String(stateInfo.playlist_id));
if (!playlistData) continue;
const spotifyTracks = spotifyPublicPlaylistStates[stateInfo.playlist_id]?.discovery_results
?.filter(result => result.spotify_data)
?.map(result => result.spotify_data) || [];
if (spotifyTracks.length > 0) {
await openDownloadMissingModalForTidal(
convertedPlaylistId,
playlistData.name,
spotifyTracks
);
const process = activeDownloadProcesses[convertedPlaylistId];
if (process) {
process.status = 'running';
process.batchId = stateInfo.download_process_id;
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';
startModalDownloadPolling(convertedPlaylistId);
}
}
} catch (error) {
console.error(`Error rehydrating Spotify public download modal for ${stateInfo.playlist_id}:`, error);
}
}
}
}
console.log('Spotify public playlist states loaded and applied');
} catch (error) {
console.error('Error loading Spotify public playlist states:', error);
}
}
async function applySpotifyPublicPlaylistState(stateInfo) {
const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo;
try {
console.log(`π΅ Applying saved state for Spotify public playlist: ${playlist_id}, Phase: ${phase}`);
const playlistData = spotifyPublicPlaylists.find(p => String(p.url_hash) === String(playlist_id));
if (!playlistData) {
console.warn(`Playlist data not found for state ${playlist_id} - skipping`);
return;
}
if (!spotifyPublicPlaylistStates[playlist_id]) {
spotifyPublicPlaylistStates[playlist_id] = {
playlist: playlistData,
phase: 'fresh'
};
}
spotifyPublicPlaylistStates[playlist_id].phase = phase;
spotifyPublicPlaylistStates[playlist_id].discovery_progress = discovery_progress;
spotifyPublicPlaylistStates[playlist_id].spotify_matches = spotify_matches;
spotifyPublicPlaylistStates[playlist_id].discovery_results = discovery_results;
spotifyPublicPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id;
spotifyPublicPlaylistStates[playlist_id].download_process_id = download_process_id;
spotifyPublicPlaylistStates[playlist_id].playlist = playlistData;
if (phase !== 'fresh' && phase !== 'discovering') {
try {
const stateResponse = await fetch(`/api/spotify-public/state/${playlist_id}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
if (fullState.discovery_results && spotifyPublicPlaylistStates[playlist_id]) {
spotifyPublicPlaylistStates[playlist_id].discovery_results = fullState.discovery_results;
spotifyPublicPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress;
spotifyPublicPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches;
spotifyPublicPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
spotifyPublicPlaylistStates[playlist_id].download_process_id = fullState.download_process_id;
}
}
} catch (error) {
console.warn(`Error fetching full discovery results for Spotify public playlist ${playlistData.name}:`, error.message);
}
}
updateSpotifyPublicCardPhase(playlist_id, phase);
if (phase === 'discovered' && spotifyPublicPlaylistStates[playlist_id]) {
const progressInfo = {
spotify_total: playlistData.track_count || playlistData.tracks?.length || 0,
spotify_matches: spotifyPublicPlaylistStates[playlist_id].spotify_matches || 0
};
updateSpotifyPublicCardProgress(playlist_id, progressInfo);
}
if (phase === 'discovering') {
const fakeUrlHash = `spotifypublic_${playlist_id}`;
startSpotifyPublicDiscoveryPolling(fakeUrlHash, playlist_id);
} else if (phase === 'syncing') {
const fakeUrlHash = `spotifypublic_${playlist_id}`;
startSpotifyPublicSyncPolling(fakeUrlHash);
}
} catch (error) {
console.error(`Error applying Spotify public playlist state for ${playlist_id}:`, error);
}
}
function updateSpotifyPublicCardPhase(urlHash, phase) {
const state = spotifyPublicPlaylistStates[urlHash];
if (!state) return;
state.phase = phase;
const card = document.getElementById(`spotify-public-card-${urlHash}`);
if (card) {
const newCardHtml = createSpotifyPublicCard(state.playlist);
card.outerHTML = newCardHtml;
const newCard = document.getElementById(`spotify-public-card-${urlHash}`);
if (newCard) {
newCard.addEventListener('click', () => handleSpotifyPublicCardClick(urlHash));
}
if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) {
setTimeout(() => {
updateSpotifyPublicCardSyncProgress(urlHash, state.lastSyncProgress);
}, 0);
}
}
}
function updateSpotifyPublicCardProgress(urlHash, progress) {
const state = spotifyPublicPlaylistStates[urlHash];
if (!state) return;
const card = document.getElementById(`spotify-public-card-${urlHash}`);
if (!card) return;
const progressElement = card.querySelector('.playlist-card-progress');
if (!progressElement) return;
progressElement.classList.remove('hidden');
const total = progress.spotify_total || 0;
const matches = progress.spotify_matches || 0;
if (total > 0) {
progressElement.innerHTML = `
β ${matches}
/
βͺ ${total}
`;
}
}
// ===============================
// SPOTIFY PUBLIC SYNC FUNCTIONALITY
// ===============================
async function startSpotifyPublicPlaylistSync(urlHash) {
try {
console.log('π΅ Starting Spotify public playlist sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_spotify_public_playlist) {
console.error('Invalid Spotify public playlist state for sync');
return;
}
const playlistId = state.spotify_public_playlist_id;
const response = await fetch(`/api/spotify-public/sync/start/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error starting sync: ${result.error}`, 'error');
return;
}
const syncPlaylistId = result.sync_playlist_id;
if (state) state.syncPlaylistId = syncPlaylistId;
updateSpotifyPublicCardPhase(playlistId, 'syncing');
updateSpotifyPublicModalButtons(urlHash, 'syncing');
startSpotifyPublicSyncPolling(urlHash, syncPlaylistId);
showToast('Spotify public playlist sync started!', 'success');
} catch (error) {
console.error('Error starting Spotify public sync:', error);
showToast(`Error starting sync: ${error.message}`, 'error');
}
}
function startSpotifyPublicSyncPolling(urlHash, syncPlaylistId) {
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
const state = youtubePlaylistStates[urlHash];
const playlistId = state.spotify_public_playlist_id;
syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId);
// WebSocket subscription
if (socketConnected && syncPlaylistId) {
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
_syncProgressCallbacks[syncPlaylistId] = (data) => {
const progress = data.progress || {};
updateSpotifyPublicCardSyncProgress(playlistId, progress);
updateSpotifyPublicModalSyncProgress(urlHash, progress);
if (data.status === 'finished') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'sync_complete';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
updateSpotifyPublicCardPhase(playlistId, 'sync_complete');
updateSpotifyPublicModalButtons(urlHash, 'sync_complete');
showToast('Spotify public playlist sync complete!', 'success');
} else if (data.status === 'error' || data.status === 'cancelled') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'discovered';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
updateSpotifyPublicCardPhase(playlistId, 'discovered');
updateSpotifyPublicModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
}
};
}
const pollFunction = async () => {
if (socketConnected) return;
try {
const response = await fetch(`/api/spotify-public/sync/status/${playlistId}`);
const status = await response.json();
if (status.error) {
console.error('Error polling Spotify public sync status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
updateSpotifyPublicCardSyncProgress(playlistId, status.progress);
updateSpotifyPublicModalSyncProgress(urlHash, status.progress);
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'sync_complete';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
updateSpotifyPublicCardPhase(playlistId, 'sync_complete');
updateSpotifyPublicModalButtons(urlHash, 'sync_complete');
showToast('Spotify public playlist sync complete!', 'success');
} else if (status.sync_status === 'error') {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'discovered';
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
updateSpotifyPublicCardPhase(playlistId, 'discovered');
updateSpotifyPublicModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Error polling Spotify public sync:', error);
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
}
};
if (!socketConnected) pollFunction();
const pollInterval = setInterval(pollFunction, 1000);
activeYouTubePollers[urlHash] = pollInterval;
}
async function cancelSpotifyPublicSync(urlHash) {
try {
console.log('Cancelling Spotify public sync:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_spotify_public_playlist) {
console.error('Invalid Spotify public playlist state');
return;
}
const playlistId = state.spotify_public_playlist_id;
const response = await fetch(`/api/spotify-public/sync/cancel/${playlistId}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error cancelling sync: ${result.error}`, 'error');
return;
}
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
const syncId = state && state.syncPlaylistId;
if (syncId && _syncProgressCallbacks[syncId]) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] });
delete _syncProgressCallbacks[syncId];
}
updateSpotifyPublicCardPhase(playlistId, 'discovered');
updateSpotifyPublicModalButtons(urlHash, 'discovered');
showToast('Spotify public sync cancelled', 'info');
} catch (error) {
console.error('Error cancelling Spotify public sync:', error);
showToast(`Error cancelling sync: ${error.message}`, 'error');
}
}
function updateSpotifyPublicCardSyncProgress(urlHash, progress) {
const state = spotifyPublicPlaylistStates[urlHash];
if (!state || !state.playlist || !progress) return;
state.lastSyncProgress = progress;
const card = document.getElementById(`spotify-public-card-${urlHash}`);
if (!card) return;
const progressElement = card.querySelector('.playlist-card-progress');
let statusCounterHTML = '';
if (progress && progress.total_tracks > 0) {
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const processed = matched + failed;
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
statusCounterHTML = `
βͺ ${total}
/
β ${matched}
/
β ${failed}
(${percentage}%)
`;
}
if (statusCounterHTML) {
progressElement.innerHTML = statusCounterHTML;
}
}
function updateSpotifyPublicModalSyncProgress(urlHash, progress) {
const statusDisplay = document.getElementById(`spotify-public-sync-status-${urlHash}`);
if (!statusDisplay || !progress) return;
const totalEl = document.getElementById(`spotify-public-total-${urlHash}`);
const matchedEl = document.getElementById(`spotify-public-matched-${urlHash}`);
const failedEl = document.getElementById(`spotify-public-failed-${urlHash}`);
const percentageEl = document.getElementById(`spotify-public-percentage-${urlHash}`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
if (total > 0) {
const processed = matched + failed;
const percentage = Math.round((processed / total) * 100);
if (percentageEl) percentageEl.textContent = percentage;
}
}
function updateSpotifyPublicModalButtons(urlHash, phase) {
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (!modal) return;
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
}
}
async function startSpotifyPublicDownloadMissing(urlHash) {
try {
console.log('π Starting download missing tracks for Spotify public playlist:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_spotify_public_playlist) {
console.error('Invalid Spotify public playlist state for download');
return;
}
const discoveryResults = state.discoveryResults || state.discovery_results;
if (!discoveryResults) {
showToast('No discovery results available for download', 'error');
return;
}
const spotifyTracks = [];
for (const result of discoveryResults) {
if (result.spotify_data) {
spotifyTracks.push(result.spotify_data);
} else if (result.spotify_track && result.status_class === 'found') {
const albumData = result.spotify_album || 'Unknown Album';
const albumObject = typeof albumData === 'object' && albumData !== null
? albumData
: {
name: typeof albumData === 'string' ? albumData : 'Unknown Album',
album_type: 'album',
images: []
};
spotifyTracks.push({
id: result.spotify_id || 'unknown',
name: result.spotify_track || 'Unknown Track',
artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'],
album: albumObject,
duration_ms: 0
});
}
}
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
const realUrlHash = state.spotify_public_playlist_id;
const virtualPlaylistId = `spotify_public_${realUrlHash}`;
const playlistName = state.playlist.name;
state.convertedSpotifyPlaylistId = virtualPlaylistId;
// Sync convertedSpotifyPlaylistId to spotifyPublicPlaylistStates for card click routing
if (realUrlHash && spotifyPublicPlaylistStates[realUrlHash]) {
spotifyPublicPlaylistStates[realUrlHash].convertedSpotifyPlaylistId = virtualPlaylistId;
}
const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (discoveryModal) {
discoveryModal.classList.add('hidden');
}
await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks);
} catch (error) {
console.error('Error starting Spotify public download missing:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
// ===============================
// URL HISTORY (Saved playlist URLs)
// ===============================
const URL_HISTORY_MAX = 10;
const URL_HISTORY_SOURCES = {
youtube: { key: 'soulsync-url-history-youtube', icon: 'βΆ', inputId: 'youtube-url-input', containerId: 'youtube-url-history', loadFn: () => parseYouTubePlaylist() },
deezer: { key: 'soulsync-url-history-deezer', icon: 'π΅', inputId: 'deezer-url-input', containerId: 'deezer-url-history', loadFn: () => loadDeezerPlaylist() },
'spotify-public': { key: 'soulsync-url-history-spotify-public', icon: 'π§', inputId: 'spotify-public-url-input', containerId: 'spotify-public-url-history', loadFn: () => parseSpotifyPublicUrl() }
};
function getUrlHistory(source) {
try {
const cfg = URL_HISTORY_SOURCES[source];
if (!cfg) return [];
const raw = localStorage.getItem(cfg.key);
return raw ? JSON.parse(raw) : [];
} catch { return []; }
}
function saveUrlHistory(source, url, name) {
const cfg = URL_HISTORY_SOURCES[source];
if (!cfg || !url) return;
let history = getUrlHistory(source);
// Remove duplicate (same URL)
history = history.filter(h => h.url !== url);
// Add to front
history.unshift({ url, name: name || url, ts: Date.now() });
// Cap
if (history.length > URL_HISTORY_MAX) history = history.slice(0, URL_HISTORY_MAX);
localStorage.setItem(cfg.key, JSON.stringify(history));
renderUrlHistory(source);
}
function removeUrlHistoryEntry(source, url) {
const cfg = URL_HISTORY_SOURCES[source];
if (!cfg) return;
let history = getUrlHistory(source);
history = history.filter(h => h.url !== url);
localStorage.setItem(cfg.key, JSON.stringify(history));
renderUrlHistory(source);
}
function renderUrlHistory(source) {
const cfg = URL_HISTORY_SOURCES[source];
if (!cfg) return;
const container = document.getElementById(cfg.containerId);
if (!container) return;
const history = getUrlHistory(source);
if (history.length === 0) {
container.style.display = 'none';
container.innerHTML = '';
return;
}
container.style.display = 'flex';
container.innerHTML = `Recent ` +
history.map(h => {
const rawName = h.name.length > 30 ? h.name.substring(0, 28) + '...' : h.name;
const safeName = escapeHtml(rawName);
const safeTitle = escapeHtml(h.name);
const safeUrl = h.url.replace(/"/g, '"');
return `
${cfg.icon}
${safeName}
×
`;
}).join('');
// Pill click β fill input and load (skip if already loaded)
container.querySelectorAll('.url-history-pill').forEach(pill => {
pill.addEventListener('click', (e) => {
// Don't trigger if clicking the X button
if (e.target.classList.contains('url-history-pill-remove')) return;
const pillUrl = pill.dataset.url;
if (_isUrlAlreadyLoaded(source, pillUrl)) {
showToast('This playlist is already loaded', 'info');
return;
}
const input = document.getElementById(cfg.inputId);
if (input) input.value = pillUrl;
cfg.loadFn();
});
});
// X button click β remove entry
container.querySelectorAll('.url-history-pill-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
removeUrlHistoryEntry(btn.dataset.source, btn.dataset.url);
});
});
}
function _isUrlAlreadyLoaded(source, url) {
if (source === 'youtube') {
// Check for existing YouTube card with this URL
const container = document.getElementById('youtube-playlist-container');
if (container) {
const cards = container.querySelectorAll('.youtube-playlist-card[data-url]');
for (const card of cards) {
if (card.dataset.url === url) return true;
}
}
return false;
} else if (source === 'deezer') {
// Extract playlist ID from URL and check deezerPlaylists array
const match = url.match(/deezer\.com\/(?:[a-z]{2}\/)?playlist\/(\d+)/i);
const id = match ? match[1] : (/^\d+$/.test(url) ? url : null);
if (id && deezerPlaylists.find(p => String(p.id) === String(id))) return true;
return false;
} else if (source === 'spotify-public') {
// Extract Spotify ID from URL and compare against loaded playlists
const spMatch = url.match(/open\.spotify\.com\/(playlist|album)\/([a-zA-Z0-9]+)/);
const spId = spMatch ? spMatch[2] : null;
if (spId && spotifyPublicPlaylists.some(p => p.id === spId)) return true;
// Fallback: direct URL comparison
return spotifyPublicPlaylists.some(p => p.url === url);
}
return false;
}
function initUrlHistories() {
for (const source of Object.keys(URL_HISTORY_SOURCES)) {
renderUrlHistory(source);
}
}
// ===============================
// YOUTUBE PLAYLIST FUNCTIONALITY
// ===============================
async function parseYouTubePlaylist() {
const urlInput = document.getElementById('youtube-url-input');
const url = urlInput.value.trim();
if (!url) {
showToast('Please enter a YouTube playlist URL', 'error');
return;
}
// Validate URL format
if (!url.includes('youtube.com/playlist') && !url.includes('music.youtube.com/playlist')) {
showToast('Please enter a valid YouTube playlist URL', 'error');
return;
}
// Check if already loaded
if (_isUrlAlreadyLoaded('youtube', url)) {
showToast('This playlist is already loaded', 'info');
urlInput.value = '';
return;
}
try {
console.log('π¬ Parsing YouTube playlist:', url);
// Create card immediately in 'fresh' phase
createYouTubeCard(url, 'fresh');
// Parse playlist via API
const response = await fetch('/api/youtube/parse', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ url: url })
});
const result = await response.json();
if (result.error) {
showToast(`Error parsing YouTube playlist: ${result.error}`, 'error');
removeYouTubeCard(url);
return;
}
console.log('β
YouTube playlist parsed:', result.name, `(${result.tracks.length} tracks)`);
// Save to URL history
saveUrlHistory('youtube', url, result.name);
// Update card with parsed data and stay in 'fresh' phase
updateYouTubeCardData(result.url_hash, result);
updateYouTubeCardPhase(result.url_hash, 'fresh');
// Auto-mirror this YouTube playlist
mirrorPlaylist('youtube', result.url_hash, result.name, result.tracks.map(t => ({
track_name: t.name || t.title || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artist || ''),
album_name: '', duration_ms: t.duration_ms || 0, source_track_id: t.id || ''
})), { description: url });
// Clear input
urlInput.value = '';
// Show success message
showToast(`YouTube playlist parsed: ${result.name} (${result.tracks.length} tracks)`, 'success');
} catch (error) {
console.error('β Error parsing YouTube playlist:', error);
showToast(`Error parsing YouTube playlist: ${error.message}`, 'error');
removeYouTubeCard(url);
}
}
function createYouTubeCard(url, phase = 'fresh') {
const container = document.getElementById('youtube-playlist-container');
const placeholder = container.querySelector('.playlist-placeholder');
// Remove placeholder if it exists
if (placeholder) {
placeholder.style.display = 'none';
}
// Create temporary URL hash for initial card
const tempHash = btoa(url).substring(0, 8);
const cardHtml = `
βΆ
Parsing YouTube playlist...
-- tracks
Loading...
βͺ 0 / β 0 / β 0 / 0%
Parsing...
`;
container.insertAdjacentHTML('beforeend', cardHtml);
// Store temporary state
youtubePlaylistStates[tempHash] = {
phase: phase,
url: url,
cardElement: document.getElementById(`youtube-card-${tempHash}`),
tempHash: tempHash
};
console.log('π Created YouTube card for URL:', url);
}
function updateYouTubeCardData(urlHash, playlistData) {
// Find the card by URL or temp hash
let state = youtubePlaylistStates[urlHash];
if (!state) {
// Look for temporary card by URL
const tempState = Object.values(youtubePlaylistStates).find(s => s.url === playlistData.url);
if (tempState) {
// Update the state with real hash
delete youtubePlaylistStates[tempState.tempHash];
youtubePlaylistStates[urlHash] = tempState;
state = tempState;
// Update card ID
if (state.cardElement) {
state.cardElement.id = `youtube-card-${urlHash}`;
}
}
}
if (!state || !state.cardElement) {
console.error('β Could not find YouTube card for hash:', urlHash);
return;
}
const card = state.cardElement;
// Update card content
const nameElement = card.querySelector('.playlist-card-name');
const trackCountElement = card.querySelector('.playlist-card-track-count');
nameElement.textContent = playlistData.name;
trackCountElement.textContent = `${playlistData.tracks.length} tracks`;
// Store playlist data
state.playlist = playlistData;
state.urlHash = urlHash;
// Add click handler for card and action button
const handleCardClick = () => handleYouTubeCardClick(urlHash);
const actionBtn = card.querySelector('.playlist-card-action-btn');
card.addEventListener('click', handleCardClick);
actionBtn.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent card click
handleCardClick();
});
console.log('π Updated YouTube card data:', playlistData.name);
}
function updateYouTubeCardPhase(urlHash, phase) {
const state = youtubePlaylistStates[urlHash];
if (!state || !state.cardElement) return;
const card = state.cardElement;
const phaseTextElement = card.querySelector('.playlist-card-phase-text');
const actionBtn = card.querySelector('.playlist-card-action-btn');
const progressElement = card.querySelector('.playlist-card-progress');
state.phase = phase;
switch (phase) {
case 'fresh':
phaseTextElement.textContent = 'Ready to discover';
phaseTextElement.style.color = '#999';
actionBtn.textContent = 'Start Discovery';
actionBtn.disabled = false;
progressElement.classList.add('hidden');
break;
case 'discovering':
phaseTextElement.textContent = 'Discovering...';
phaseTextElement.style.color = '#ffa500'; // Orange
actionBtn.textContent = 'View Progress';
actionBtn.disabled = false;
progressElement.classList.remove('hidden');
break;
case 'discovered':
phaseTextElement.textContent = 'Discovery Complete';
phaseTextElement.style.color = 'rgb(var(--accent-rgb))'; // Green
actionBtn.textContent = 'View Details';
actionBtn.disabled = false;
progressElement.classList.add('hidden');
break;
case 'syncing':
phaseTextElement.textContent = 'Syncing...';
phaseTextElement.style.color = '#ffa500'; // Orange
actionBtn.textContent = 'View Progress';
actionBtn.disabled = false;
progressElement.classList.remove('hidden');
break;
case 'sync_complete':
phaseTextElement.textContent = 'Sync Complete';
phaseTextElement.style.color = 'rgb(var(--accent-rgb))'; // Green
actionBtn.textContent = 'View Details';
actionBtn.disabled = false;
progressElement.classList.add('hidden');
break;
case 'downloading':
phaseTextElement.textContent = 'Downloading...';
phaseTextElement.style.color = '#ffa500'; // Orange
actionBtn.textContent = 'View Downloads';
actionBtn.disabled = false;
progressElement.classList.remove('hidden');
break;
case 'download_complete':
phaseTextElement.textContent = 'Download Complete';
phaseTextElement.style.color = 'rgb(var(--accent-rgb))'; // Green
actionBtn.textContent = 'View Results';
actionBtn.disabled = false;
progressElement.classList.add('hidden');
break;
}
console.log('π Updated YouTube card phase:', urlHash, phase);
}
function handleYouTubeCardClick(urlHash) {
const state = youtubePlaylistStates[urlHash];
if (!state) return;
switch (state.phase) {
case 'fresh':
// First click: Start discovery and open modal
console.log('π¬ Starting YouTube discovery for first time:', urlHash);
updateYouTubeCardPhase(urlHash, 'discovering');
startYouTubeDiscovery(urlHash);
openYouTubeDiscoveryModal(urlHash);
break;
case 'discovering':
case 'discovered':
case 'syncing':
case 'sync_complete':
// Open discovery modal with current state
console.log('π¬ Opening YouTube discovery modal:', urlHash);
openYouTubeDiscoveryModal(urlHash);
break;
case 'downloading':
case 'download_complete':
// Open download missing tracks modal
console.log('π¬ Opening download modal for YouTube playlist:', urlHash);
// Need to get playlist ID from converted Spotify data
const spotifyPlaylistId = state.convertedSpotifyPlaylistId;
if (spotifyPlaylistId) {
// Check if we have discovery results, if not load them first
if (!state.discoveryResults || state.discoveryResults.length === 0) {
console.log('π Loading discovery results for download modal...');
fetch(`/api/youtube/state/${urlHash}`)
.then(response => response.json())
.then(fullState => {
if (fullState.discovery_results) {
state.discoveryResults = fullState.discovery_results;
console.log(`β
Loaded ${state.discoveryResults.length} discovery results`);
// Now open the modal with the loaded data
const playlistName = state.playlist.name;
const spotifyTracks = state.discoveryResults
.filter(result => result.spotify_data)
.map(result => result.spotify_data);
openDownloadMissingModalForYouTube(spotifyPlaylistId, playlistName, spotifyTracks);
} else {
console.error('β No discovery results found for downloads');
showToast('Unable to open download modal - no discovery data', 'error');
}
})
.catch(error => {
console.error('β Error loading discovery results:', error);
showToast('Error loading playlist data', 'error');
});
} else {
// Use the YouTube-specific function to maintain proper state linking
const playlistName = state.playlist.name;
const spotifyTracks = state.discoveryResults
.filter(result => result.spotify_data)
.map(result => result.spotify_data);
openDownloadMissingModalForYouTube(spotifyPlaylistId, playlistName, spotifyTracks);
}
} else {
console.error('β No converted Spotify playlist ID found for downloads');
showToast('Unable to open download modal - missing playlist data', 'error');
}
break;
}
}
function updateYouTubeCardProgress(urlHash, progress) {
const state = youtubePlaylistStates[urlHash];
if (!state || !state.cardElement) return;
const card = state.cardElement;
const progressElement = card.querySelector('.playlist-card-progress');
const total = progress.spotify_total || 0;
const matches = progress.spotify_matches || 0;
const failed = total - matches;
const percentage = total > 0 ? Math.round((matches / total) * 100) : 0;
progressElement.textContent = `βͺ ${total} / β ${matches} / β ${failed} / ${percentage}%`;
console.log('π Updated YouTube card progress:', urlHash, `${matches}/${total} (${percentage}%)`);
}
function removeYouTubeCard(url) {
const state = Object.values(youtubePlaylistStates).find(s => s.url === url);
if (state && state.cardElement) {
state.cardElement.remove();
// Remove from state
if (state.urlHash) {
delete youtubePlaylistStates[state.urlHash];
} else if (state.tempHash) {
delete youtubePlaylistStates[state.tempHash];
}
}
// Show placeholder if no cards left
const container = document.getElementById('youtube-playlist-container');
const cards = container.querySelectorAll('.youtube-playlist-card');
const placeholder = container.querySelector('.playlist-placeholder');
if (cards.length === 0 && placeholder) {
placeholder.style.display = 'block';
}
}
async function startYouTubeDiscovery(urlHash) {
try {
console.log('π Starting YouTube Spotify discovery for:', urlHash);
const response = await fetch(`/api/youtube/discovery/start/${urlHash}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error starting discovery: ${result.error}`, 'error');
return;
}
// Update frontend phase to match backend
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
if (state) {
state.phase = 'discovering';
}
// Update modal buttons to show "Discovering..." instead of "Start Discovery"
updateYouTubeModalButtons(urlHash, 'discovering');
// Start polling for progress
startYouTubeDiscoveryPolling(urlHash);
// Open discovery modal
openYouTubeDiscoveryModal(urlHash);
} catch (error) {
console.error('β Error starting YouTube discovery:', error);
showToast(`Error starting discovery: ${error.message}`, 'error');
}
}
function startYouTubeDiscoveryPolling(urlHash) {
// Stop any existing polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('discovery:subscribe', { ids: [urlHash] });
_discoveryProgressCallbacks[urlHash] = (data) => {
if (data.error) {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash];
return;
}
updateYouTubeCardProgress(urlHash, data);
const st = youtubePlaylistStates[urlHash];
if (st) { st.discoveryResults = data.results || []; st.discovery_results = data.results || []; st.discoveryProgress = data.progress || 0; st.spotifyMatches = data.spotify_matches || 0; st.spotify_matches = data.spotify_matches || 0; }
updateYouTubeDiscoveryModal(urlHash, data);
if (data.complete) {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash];
// Update phase in state directly (updateYouTubeCardPhase may skip if no cardElement)
if (st) st.phase = 'discovered';
updateYouTubeCardPhase(urlHash, 'discovered');
updateYouTubeModalButtons(urlHash, 'discovered');
showToast('Discovery complete!', 'success');
}
};
}
const pollInterval = setInterval(async () => {
// Always poll β no dedicated WebSocket events for discovery progress
try {
const response = await fetch(`/api/youtube/discovery/status/${urlHash}`);
const status = await response.json();
if (status.error) {
console.error('β Error polling YouTube discovery status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
// Update card progress
updateYouTubeCardProgress(urlHash, status);
// Store discovery results and progress in state
const state = youtubePlaylistStates[urlHash];
if (state) {
state.discoveryResults = status.results || [];
state.discovery_results = status.results || [];
state.discoveryProgress = status.progress || 0;
state.spotifyMatches = status.spotify_matches || 0;
state.spotify_matches = status.spotify_matches || 0;
}
// Update modal if open
updateYouTubeDiscoveryModal(urlHash, status);
// Check if complete
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
// Update phase in state directly (updateYouTubeCardPhase may skip if no cardElement)
if (state) state.phase = 'discovered';
// Update card phase to discovered
updateYouTubeCardPhase(urlHash, 'discovered');
// Update modal buttons to show sync and download buttons
updateYouTubeModalButtons(urlHash, 'discovered');
console.log('β
Discovery complete:', urlHash);
showToast('Discovery complete!', 'success');
}
} catch (error) {
console.error('β Error polling YouTube discovery:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
}
}, 1000);
activeYouTubePollers[urlHash] = pollInterval;
}
function stopYouTubeDiscoveryPolling(urlHash) {
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
console.log('βΉ Stopped YouTube discovery polling for:', urlHash);
}
}
function openYouTubeDiscoveryModal(urlHash) {
// Check ListenBrainz state first, then fallback to YouTube state
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
if (!state || !state.playlist) {
console.error('β No playlist data found for identifier:', urlHash);
return;
}
console.log('π΅ Opening discovery modal for:', state.playlist.name);
// Check if modal already exists
let modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (modal) {
// Modal exists, just show it
modal.classList.remove('hidden');
console.log('π Showing existing modal with preserved state');
console.log('π Current discovery results count:', state.discoveryResults?.length || state.discovery_results?.length || 0);
// Resume polling if discovery or sync is in progress
if (state.phase === 'discovering' && !activeYouTubePollers[urlHash]) {
console.log('π Resuming discovery polling...');
startYouTubeDiscoveryPolling(urlHash);
} else if (state.phase === 'syncing' && !activeYouTubePollers[urlHash]) {
console.log('π Resuming sync polling...');
if (state.is_tidal_playlist) {
startTidalSyncPolling(urlHash);
} else if (state.is_deezer_playlist) {
startDeezerSyncPolling(urlHash);
} else if (state.is_spotify_public_playlist) {
startSpotifyPublicSyncPolling(urlHash);
} else if (state.is_beatport_playlist) {
startBeatportSyncPolling(urlHash);
} else if (state.is_listenbrainz_playlist) {
startListenBrainzSyncPolling(urlHash);
} else {
startYouTubeSyncPolling(urlHash);
}
}
} else {
// Create new modal (support YouTube, Tidal, Deezer, Beatport, ListenBrainz, Spotify Public, and Mirrored)
const isTidal = state.is_tidal_playlist;
const isDeezer = state.is_deezer_playlist;
const isSpotifyPublic = state.is_spotify_public_playlist;
const isBeatport = state.is_beatport_playlist;
const isListenBrainz = state.is_listenbrainz_playlist;
const isMirrored = state.is_mirrored_playlist;
const isLastfmRadio = typeof urlHash === 'string' && urlHash.startsWith('lastfm_radio_');
const modalTitle = isMirrored ? 'π΅ Mirrored Playlist Discovery' :
isSpotifyPublic ? 'π΅ Spotify Playlist Discovery' :
isDeezer ? 'π΅ Deezer Playlist Discovery' :
isTidal ? 'π΅ Tidal Playlist Discovery' :
isBeatport ? 'π΅ Beatport Chart Discovery' :
isLastfmRadio ? 'π» Last.fm Radio Discovery' :
isListenBrainz ? 'π΅ ListenBrainz Playlist Discovery' :
'π΅ YouTube Playlist Discovery';
const sourceLabel = isMirrored ? (state.mirrored_source ? state.mirrored_source.charAt(0).toUpperCase() + state.mirrored_source.slice(1) : 'Source') :
isSpotifyPublic ? 'Spotify' :
isDeezer ? 'Deezer' :
isTidal ? 'Tidal' :
isBeatport ? 'Beatport' :
isLastfmRadio ? 'Last.fm' :
isListenBrainz ? 'LB' :
'YT';
const modalHtml = `
π ${currentMusicSourceName} Discovery Progress
${getInitialProgressText(state.phase, isTidal, isBeatport, isListenBrainz)}
${sourceLabel} Track
${sourceLabel} Artist
Status
${currentMusicSourceName} Track
${currentMusicSourceName} Artist
Album
Actions
${generateTableRowsFromState(state, urlHash)}
`;
// Add modal to DOM
document.body.insertAdjacentHTML('beforeend', modalHtml);
modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
// Store modal reference
state.modalElement = modal;
// Set initial progress if we have discovery results
if (state.discoveryResults && state.discoveryResults.length > 0) {
// Compute progress from results if discoveryProgress is missing/zero
let progress = state.discoveryProgress || 0;
const matches = state.spotifyMatches || 0;
if (progress === 0 && state.discoveryResults.length > 0 && state.playlist.tracks.length > 0) {
progress = Math.min(100, Math.round((state.discoveryResults.length / state.playlist.tracks.length) * 100));
}
const progressData = {
progress: progress,
spotify_matches: matches || state.discoveryResults.filter(r => r.status_class === 'found').length,
spotify_total: state.playlist.tracks.length,
results: state.discoveryResults
};
updateYouTubeDiscoveryModal(urlHash, progressData);
}
// Start polling immediately if modal is opened in syncing phase
if (state.phase === 'syncing') {
console.log('π Modal opened in syncing phase - starting immediate polling...');
if (state.is_tidal_playlist) {
startTidalSyncPolling(urlHash);
} else if (state.is_deezer_playlist) {
startDeezerSyncPolling(urlHash);
} else if (state.is_spotify_public_playlist) {
startSpotifyPublicSyncPolling(urlHash);
} else if (state.is_beatport_playlist) {
startBeatportSyncPolling(urlHash);
} else {
startYouTubeSyncPolling(urlHash);
}
}
console.log('β¨ Created new modal with current state');
}
}
function getModalActionButtons(urlHash, phase, state = null) {
// Get state if not provided
if (!state) {
state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
}
const isTidal = state && state.is_tidal_playlist;
const isDeezer = state && state.is_deezer_playlist;
const isSpotifyPublic = state && state.is_spotify_public_playlist;
const isBeatport = state && state.is_beatport_playlist;
const isListenBrainz = state && state.is_listenbrainz_playlist;
// Validate data availability for buttons (support both naming conventions)
const hasDiscoveryResults = state && ((state.discoveryResults && state.discoveryResults.length > 0) || (state.discovery_results && state.discovery_results.length > 0));
const hasSpotifyMatches = state && ((state.spotifyMatches > 0) || (state.spotify_matches > 0));
const hasConvertedPlaylistId = state && state.convertedSpotifyPlaylistId;
switch (phase) {
case 'fresh':
case 'discovering':
// Show start discovery button for fresh playlists
if (phase === 'fresh') {
const wingItBtn = ` β‘ Wing It `;
if (isListenBrainz) {
return `π Start Discovery ${wingItBtn}`;
} else {
return `π Start Discovery ${wingItBtn}`;
}
} else {
// Discovering phase - show progress
return `π Discovering ${currentMusicSourceName} matches...
`;
}
case 'discovered':
case 'downloading':
case 'download_complete':
// Only show buttons if we actually have discovery data
if (!hasDiscoveryResults) {
return `β οΈ No discovery results available. Try starting discovery again.
`;
}
let buttons = '';
// Only show sync button if there are Spotify matches (and not standalone mode)
if (hasSpotifyMatches && !_isSoulsyncStandalone) {
if (isListenBrainz) {
buttons += `π Sync This Playlist `;
} else if (isTidal) {
buttons += `π Sync This Playlist `;
} else if (isDeezer) {
buttons += `π Sync This Playlist `;
} else if (isSpotifyPublic) {
buttons += `π Sync This Playlist `;
} else if (isBeatport) {
buttons += `π Sync This Playlist `;
} else {
buttons += `π Sync This Playlist `;
}
}
// Only show download button if we have matches or a converted playlist ID
if (hasSpotifyMatches || hasConvertedPlaylistId) {
if (isListenBrainz) {
buttons += `π Download Missing Tracks `;
} else if (isTidal) {
buttons += `π Download Missing Tracks `;
} else if (isDeezer) {
buttons += `π Download Missing Tracks `;
} else if (isSpotifyPublic) {
buttons += `π Download Missing Tracks `;
} else if (isBeatport) {
buttons += `π Download Missing Tracks `;
} else {
buttons += `π Download Missing Tracks `;
}
}
// Retry Failed button for mirrored playlists
if (state && state.is_mirrored_playlist) {
const results = state.discovery_results || state.discoveryResults || [];
const failedCount = results.filter(r => r.status_class !== 'found').length;
if (failedCount > 0) {
buttons += `π Retry Failed (${failedCount}) `;
}
}
// Rediscover button β reset and re-run discovery (only for sources with reset endpoints)
if (isBeatport) {
buttons += `π Rediscover `;
} else if (!isListenBrainz && !isTidal && !isDeezer && !isSpotifyPublic) {
buttons += `π Rediscover `;
}
// Wing It button β available in discovered phase
buttons += ` β‘ Wing It `;
if (!buttons || buttons.trim().startsWith('βΉοΈ No Spotify matches found.` + buttons;
}
return buttons;
case 'syncing':
if (isListenBrainz) {
return `
β Cancel Sync
βͺ 0
/
β 0
/
β 0
(0 %)
`;
} else if (isTidal) {
return `
β Cancel Sync
βͺ 0
/
β 0
/
β 0
(0 %)
`;
} else if (isDeezer) {
return `
β Cancel Sync
βͺ 0
/
β 0
/
β 0
(0 %)
`;
} else if (isSpotifyPublic) {
return `
β Cancel Sync
βͺ 0
/
β 0
/
β 0
(0 %)
`;
} else if (isBeatport) {
return `
β Cancel Sync
βͺ 0
/
β 0
/
β 0
(0 %)
`;
} else {
return `
β Cancel Sync
βͺ 0
/
β 0
/
β 0
(0 %)
`;
}
case 'sync_complete':
let syncCompleteButtons = '';
// Only show sync button if there are Spotify matches (and not standalone mode)
if (hasSpotifyMatches && !_isSoulsyncStandalone) {
if (isListenBrainz) {
syncCompleteButtons += `π Sync This Playlist `;
} else if (isTidal) {
syncCompleteButtons += `π Sync This Playlist `;
} else if (isSpotifyPublic) {
syncCompleteButtons += `π Sync This Playlist `;
} else if (isBeatport) {
syncCompleteButtons += `π Sync This Playlist `;
} else {
syncCompleteButtons += `π Sync This Playlist `;
}
}
// Only show download button if we have matches or a converted playlist ID
if (hasSpotifyMatches || hasConvertedPlaylistId) {
if (isListenBrainz) {
syncCompleteButtons += `π Download Missing Tracks `;
} else if (isTidal) {
syncCompleteButtons += `π Download Missing Tracks `;
} else if (isSpotifyPublic) {
syncCompleteButtons += `π Download Missing Tracks `;
} else if (isBeatport) {
syncCompleteButtons += `π Download Missing Tracks `;
} else {
syncCompleteButtons += `π Download Missing Tracks `;
}
}
// Rediscover button (only for sources with reset endpoints)
if (isBeatport) {
syncCompleteButtons += `π Rediscover `;
} else if (!isListenBrainz && !isTidal && !isDeezer && !isSpotifyPublic) {
syncCompleteButtons += `π Rediscover `;
}
// Wing It button
syncCompleteButtons += ` β‘ Wing It `;
return syncCompleteButtons;
case 'download_complete':
// Same options as sync_complete β allow re-sync, download missing, and reset
let dlCompleteButtons = '';
if (hasSpotifyMatches) {
if (isListenBrainz) {
dlCompleteButtons += `π Sync This Playlist `;
} else if (isTidal) {
dlCompleteButtons += `π Sync This Playlist `;
} else if (isDeezer) {
dlCompleteButtons += `π Sync This Playlist `;
} else if (isSpotifyPublic) {
dlCompleteButtons += `π Sync This Playlist `;
} else if (isBeatport) {
dlCompleteButtons += `π Sync This Playlist `;
} else {
dlCompleteButtons += `π Sync This Playlist `;
}
}
if (hasSpotifyMatches || hasConvertedPlaylistId) {
if (isListenBrainz) {
dlCompleteButtons += `π Download Missing Tracks `;
} else if (isTidal) {
dlCompleteButtons += `π Download Missing Tracks `;
} else if (isDeezer) {
dlCompleteButtons += `π Download Missing Tracks `;
} else if (isSpotifyPublic) {
dlCompleteButtons += `π Download Missing Tracks `;
} else if (isBeatport) {
dlCompleteButtons += `π Download Missing Tracks `;
} else {
dlCompleteButtons += `π Download Missing Tracks `;
}
}
// Rediscover button (only for sources with reset endpoints)
if (isBeatport) {
dlCompleteButtons += `π Rediscover `;
} else if (!isListenBrainz && !isTidal && !isDeezer && !isSpotifyPublic) {
dlCompleteButtons += `π Rediscover `;
}
return dlCompleteButtons;
default:
return '';
}
}
function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false, isSpotifyPublic = false, isLastfmRadio = false) {
const source = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'Spotify' : (isDeezer ? 'Deezer' : (isLastfmRadio ? 'Last.fm Radio' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'))))));
switch (phase) {
case 'fresh':
return `Ready to discover clean ${currentMusicSourceName} metadata for ${source} tracks...`;
case 'discovering':
return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`;
case 'discovered':
case 'downloading':
case 'download_complete':
return 'Discovery complete! View the results below.';
default:
return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`;
}
}
function getInitialProgressText(phase, isTidal = false, isBeatport = false, isListenBrainz = false) {
switch (phase) {
case 'fresh':
return 'Click Start Discovery to begin...';
case 'discovering':
return 'Starting discovery...';
case 'discovered':
case 'downloading':
case 'download_complete':
return 'Discovery completed!';
default:
return 'Starting discovery...';
}
}
function generateTableRowsFromState(state, urlHash) {
const isTidal = state.is_tidal_playlist;
const isDeezer = state.is_deezer_playlist;
const isSpotifyPublic = state.is_spotify_public_playlist;
const isBeatport = state.is_beatport_playlist;
const isListenBrainz = state.is_listenbrainz_playlist;
const isMirrored = state.is_mirrored_playlist;
const platform = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'spotify_public' : (isDeezer ? 'deezer' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isBeatport ? 'beatport' : 'youtube')))));
// Support both camelCase and snake_case
const discoveryResults = state.discoveryResults || state.discovery_results;
if (discoveryResults && discoveryResults.length > 0) {
// Generate rows from existing discovery results
return discoveryResults.map((result, index) => {
// Handle different field names based on platform
const trackName = result.lb_track || result.yt_track || result.track_name || '-';
const artistName = result.lb_artist || result.yt_artist || result.artist_name || '-';
return `
${trackName}
${artistName}
${result.status}
${result.spotify_track || '-'}
${result.spotify_artist || '-'}
${result.spotify_album || '-'}
${generateDiscoveryActionButton(result, urlHash, platform)}
`;
}).join('');
} else {
// Generate initial rows from playlist tracks
return generateInitialTableRows(state.playlist.tracks, isTidal, urlHash, isBeatport, isListenBrainz);
}
}
function generateInitialTableRows(tracks, isTidal = false, urlHash = '', isBeatport = false, isListenBrainz = false) {
return tracks.map((track, index) => {
// Handle different track formats based on platform
let trackName, artistName;
if (isListenBrainz) {
// ListenBrainz tracks have track_name and artist_name
trackName = track.track_name || 'Unknown Track';
artistName = track.artist_name || 'Unknown Artist';
} else {
// YouTube/Tidal/Beatport tracks have name and artists
trackName = track.name || 'Unknown Track';
artistName = track.artists ? (Array.isArray(track.artists) ? track.artists.join(', ') : track.artists) : 'Unknown Artist';
}
return `
${trackName}
${artistName}
π Pending...
-
-
-
-
`;
}).join('');
}
function formatDuration(durationMs) {
if (!durationMs) return '0:00';
const minutes = Math.floor(durationMs / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
/**
* Generate action button for discovery table row
*/
function generateDiscoveryActionButton(result, identifier, platform) {
// Show fix button for not_found, error, or any non-found status
const isNotFound = result.status === 'not_found' ||
result.status_class === 'not-found' ||
result.status === 'β Not Found' ||
result.status === 'Not Found';
const isError = result.status === 'error' ||
result.status_class === 'error' ||
result.status === 'β Error';
const isWingIt = result.wing_it_fallback ||
result.status_class === 'wing-it';
const isFound = result.status === 'found' ||
result.status_class === 'found' ||
result.status === 'β
Found';
if (isNotFound || isError) {
return `
π§ Fix
`;
}
// For wing-it fallbacks, show fix button so user can find a real match
if (isWingIt) {
return `
π§ Fix
`;
}
// For found matches, show re-match and unmatch buttons
if (isFound) {
return `
β»
β
`;
}
return '-';
}
function updateYouTubeDiscoveryModal(urlHash, status) {
const progressBar = document.getElementById(`youtube-discovery-progress-${urlHash}`);
const progressText = document.getElementById(`youtube-discovery-progress-text-${urlHash}`);
const tableBody = document.getElementById(`youtube-discovery-table-${urlHash}`);
if (!progressBar || !progressText || !tableBody) {
console.warn(`β οΈ Missing modal elements for ${urlHash}:`, {
progressBar: !!progressBar,
progressText: !!progressText,
tableBody: !!tableBody
});
return;
}
// Update progress bar
progressBar.style.width = `${status.progress}%`;
progressText.textContent = `${status.spotify_matches} / ${status.spotify_total} tracks matched (${status.progress}%)`;
// Update table rows
status.results.forEach(result => {
const row = document.getElementById(`discovery-row-${urlHash}-${result.index}`);
if (!row) return;
const statusCell = row.querySelector('.discovery-status');
const spotifyTrackCell = row.querySelector('.spotify-track');
const spotifyArtistCell = row.querySelector('.spotify-artist');
const spotifyAlbumCell = row.querySelector('.spotify-album');
const actionsCell = row.querySelector('.discovery-actions');
statusCell.textContent = result.status;
statusCell.className = `discovery-status ${result.status_class}`;
spotifyTrackCell.textContent = result.spotify_track || '-';
spotifyArtistCell.textContent = result.spotify_artist || '-';
spotifyAlbumCell.textContent = result.spotify_album || '-';
// Update actions cell with appropriate button
if (actionsCell) {
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
const platform = state?.is_mirrored_playlist ? 'mirrored' :
(state?.is_spotify_public_playlist ? 'spotify_public' :
(state?.is_deezer_playlist ? 'deezer' :
(state?.is_listenbrainz_playlist ? 'listenbrainz' :
(state?.is_tidal_playlist ? 'tidal' :
(state?.is_beatport_playlist ? 'beatport' : 'youtube')))));
actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform);
}
});
// Update action buttons and description when discovery is complete.
// status.complete is explicitly set by LB/WS polling callers; only act when transitioning
// from 'discovering' to avoid interfering with download/sync phases of other playlist types.
if (status.complete) {
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
if (state && state.phase === 'discovering') {
state.phase = 'discovered';
const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`);
if (actionButtonsContainer) {
actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state);
console.log(`β¨ Updated action buttons for completed discovery: ${urlHash}`);
}
const descEl = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-description`);
if (descEl) descEl.textContent = 'Discovery complete! View the results below.';
} else if (state && state.phase === 'discovered') {
// Already discovered β ensure buttons are correct (e.g. after rehydration)
const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`);
if (actionButtonsContainer && actionButtonsContainer.querySelector('.modal-info')) {
actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state);
}
}
}
}
function refreshYouTubeDiscoveryModalTable(urlHash) {
const state = youtubePlaylistStates[urlHash];
if (!state || !state.modalElement) {
console.warn(`β οΈ Cannot refresh modal table: no state or modal for ${urlHash}`);
return;
}
console.log(`π Refreshing modal table with ${state.discoveryResults?.length || 0} discovery results`);
// Update the table body with new discovery results
const tableBody = state.modalElement.querySelector(`#youtube-discovery-table-${urlHash}`);
if (tableBody) {
tableBody.innerHTML = generateTableRowsFromState(state, urlHash);
console.log(`β
Modal table refreshed with discovery data`);
} else {
console.warn(`β οΈ Could not find table body for modal ${urlHash}`);
}
// Update the progress bar and footer buttons too
if (state.discoveryResults && state.discoveryResults.length > 0) {
const progressData = {
progress: state.discoveryProgress || 100,
spotify_matches: state.spotifyMatches || 0,
spotify_total: state.playlist.tracks.length,
results: state.discoveryResults
};
updateYouTubeDiscoveryModal(urlHash, progressData);
}
}
function closeYouTubeDiscoveryModal(urlHash) {
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (modal) {
// Hide modal instead of removing it to preserve state
modal.classList.add('hidden');
console.log('πͺ Hidden YouTube discovery modal (preserving state):', urlHash);
}
// Handle phase reset for completed discovery (Tidal/Beatport pattern)
const state = youtubePlaylistStates[urlHash];
if (state) {
const isTidal = state.is_tidal_playlist;
const isDeezer = state.is_deezer_playlist;
const isSpotifyPublic = state.is_spotify_public_playlist;
const isBeatport = state.is_beatport_playlist;
// Reset to 'discovered' phase if modal is closed after completion (like Tidal does)
if (state.phase === 'sync_complete' || state.phase === 'download_complete') {
console.log(`π§Ή [Modal Close] Resetting ${isSpotifyPublic ? 'Spotify Public' : (isDeezer ? 'Deezer' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')))} state after completion`);
if (isSpotifyPublic) {
// Spotify Public: Extract url_hash and reset state
const spUrlHash = state.spotify_public_playlist_id || null;
if (spUrlHash && spotifyPublicPlaylistStates[spUrlHash]) {
const preservedData = {
playlist: spotifyPublicPlaylistStates[spUrlHash].playlist,
discovery_results: spotifyPublicPlaylistStates[spUrlHash].discovery_results,
spotify_matches: spotifyPublicPlaylistStates[spUrlHash].spotify_matches,
discovery_progress: spotifyPublicPlaylistStates[spUrlHash].discovery_progress,
convertedSpotifyPlaylistId: spotifyPublicPlaylistStates[spUrlHash].convertedSpotifyPlaylistId
};
delete spotifyPublicPlaylistStates[spUrlHash].download_process_id;
delete spotifyPublicPlaylistStates[spUrlHash].phase;
Object.assign(spotifyPublicPlaylistStates[spUrlHash], preservedData);
spotifyPublicPlaylistStates[spUrlHash].phase = 'discovered';
updateSpotifyPublicCardPhase(spUrlHash, 'discovered');
try {
fetch(`/api/spotify-public/update_phase/${spUrlHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
} catch (error) {
console.warn('Error updating backend Spotify Public phase:', error);
}
}
} else if (isDeezer) {
// Deezer: Extract playlist ID and reset Deezer state
const deezerPlaylistId = state.deezer_playlist_id || null;
if (deezerPlaylistId && deezerPlaylistStates[deezerPlaylistId]) {
const preservedData = {
playlist: deezerPlaylistStates[deezerPlaylistId].playlist,
discovery_results: deezerPlaylistStates[deezerPlaylistId].discovery_results,
spotify_matches: deezerPlaylistStates[deezerPlaylistId].spotify_matches,
discovery_progress: deezerPlaylistStates[deezerPlaylistId].discovery_progress,
convertedSpotifyPlaylistId: deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId
};
delete deezerPlaylistStates[deezerPlaylistId].download_process_id;
delete deezerPlaylistStates[deezerPlaylistId].phase;
Object.assign(deezerPlaylistStates[deezerPlaylistId], preservedData);
deezerPlaylistStates[deezerPlaylistId].phase = 'discovered';
updateDeezerCardPhase(deezerPlaylistId, 'discovered');
try {
fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
} catch (error) {
console.warn('Error updating backend Deezer phase:', error);
}
}
} else if (isTidal) {
// Tidal: Extract playlist ID and reset Tidal state
const tidalPlaylistId = state.tidal_playlist_id || null;
if (tidalPlaylistId && tidalPlaylistStates[tidalPlaylistId]) {
// Preserve discovery data but reset phase
const preservedData = {
playlist: tidalPlaylistStates[tidalPlaylistId].playlist,
discovery_results: tidalPlaylistStates[tidalPlaylistId].discovery_results,
spotify_matches: tidalPlaylistStates[tidalPlaylistId].spotify_matches,
discovery_progress: tidalPlaylistStates[tidalPlaylistId].discovery_progress,
convertedSpotifyPlaylistId: tidalPlaylistStates[tidalPlaylistId].convertedSpotifyPlaylistId
};
// Clear download state
delete tidalPlaylistStates[tidalPlaylistId].download_process_id;
delete tidalPlaylistStates[tidalPlaylistId].phase;
// Restore preserved data and set to discovered phase
Object.assign(tidalPlaylistStates[tidalPlaylistId], preservedData);
tidalPlaylistStates[tidalPlaylistId].phase = 'discovered';
updateTidalCardPhase(tidalPlaylistId, 'discovered');
// Update backend state
try {
fetch(`/api/tidal/update_phase/${tidalPlaylistId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
} catch (error) {
console.warn('β οΈ Error updating backend Tidal phase:', error);
}
}
} else if (isBeatport) {
// Beatport: Reset chart state
const chartHash = state.beatport_chart_hash || urlHash;
if (beatportChartStates[chartHash]) {
beatportChartStates[chartHash].phase = 'discovered';
updateBeatportCardPhase(chartHash, 'discovered');
// Update backend state
try {
fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
} catch (error) {
console.warn('β οΈ Error updating backend Beatport phase:', error);
}
}
} else {
// YouTube: Reset to discovered phase
updateYouTubeCardPhase(urlHash, 'discovered');
// Update backend state
try {
fetch(`/api/youtube/update_phase/${urlHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
} catch (error) {
console.warn('β οΈ Error updating backend YouTube phase:', error);
}
}
// Reset frontend state to discovered
state.phase = 'discovered';
console.log(`β
[Modal Close] Reset to discovered phase: ${urlHash}`);
}
}
// Keep modal reference and all state intact
// Discovery polling continues in background if active
}
// ===============================
// YOUTUBE SYNC FUNCTIONALITY
// ===============================
async function startYouTubePlaylistSync(urlHash) {
try {
console.log('π Starting YouTube playlist sync:', urlHash);
const response = await fetch(`/api/youtube/sync/start/${urlHash}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error starting sync: ${result.error}`, 'error');
return;
}
// Capture sync_playlist_id for WebSocket subscription
const syncPlaylistId = result.sync_playlist_id;
const ytState = youtubePlaylistStates[urlHash];
if (ytState) ytState.syncPlaylistId = syncPlaylistId;
// Update card and modal to syncing phase
updateYouTubeCardPhase(urlHash, 'syncing');
// Update modal buttons if modal is open
updateYouTubeModalButtons(urlHash, 'syncing');
// Start sync polling
startYouTubeSyncPolling(urlHash, syncPlaylistId);
showToast('YouTube playlist sync started!', 'success');
} catch (error) {
console.error('β Error starting YouTube sync:', error);
showToast(`Error starting sync: ${error.message}`, 'error');
}
}
function startYouTubeSyncPolling(urlHash, syncPlaylistId) {
// Stop any existing polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
// Resolve syncPlaylistId from argument or stored state
const ytState = youtubePlaylistStates[urlHash];
syncPlaylistId = syncPlaylistId || (ytState && ytState.syncPlaylistId);
// Phase 6: Subscribe via WebSocket
if (socketConnected && syncPlaylistId) {
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
_syncProgressCallbacks[syncPlaylistId] = (data) => {
const progress = data.progress || {};
updateYouTubeCardSyncProgress(urlHash, progress);
updateYouTubeModalSyncProgress(urlHash, progress);
if (data.status === 'finished') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
updateYouTubeCardPhase(urlHash, 'sync_complete');
updateYouTubeModalButtons(urlHash, 'sync_complete');
showToast('YouTube playlist sync complete!', 'success');
} else if (data.status === 'error' || data.status === 'cancelled') {
if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
updateYouTubeCardPhase(urlHash, 'discovered');
updateYouTubeModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
}
};
}
// Define the polling function (HTTP fallback)
const pollFunction = async () => {
if (socketConnected) return; // Phase 6: WS handles updates
try {
const response = await fetch(`/api/youtube/sync/status/${urlHash}`);
const status = await response.json();
if (status.error) {
console.error('β Error polling YouTube sync status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
return;
}
updateYouTubeCardSyncProgress(urlHash, status.progress);
updateYouTubeModalSyncProgress(urlHash, status.progress);
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
updateYouTubeCardPhase(urlHash, 'sync_complete');
updateYouTubeModalButtons(urlHash, 'sync_complete');
showToast('YouTube playlist sync complete!', 'success');
} else if (status.sync_status === 'error') {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
updateYouTubeCardPhase(urlHash, 'discovered');
updateYouTubeModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('β Error polling YouTube sync:', error);
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
}
};
// Run immediately to get current status (skip if WS active)
if (!socketConnected) pollFunction();
// Then continue polling at regular intervals
const pollInterval = setInterval(pollFunction, 1000);
activeYouTubePollers[urlHash] = pollInterval;
}
async function cancelYouTubeSync(urlHash) {
try {
console.log('β Cancelling YouTube sync:', urlHash);
const response = await fetch(`/api/youtube/sync/cancel/${urlHash}`, {
method: 'POST'
});
const result = await response.json();
if (result.error) {
showToast(`Error cancelling sync: ${result.error}`, 'error');
return;
}
// Stop polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Phase 6: Clean up WS subscription
const ytCancelState = youtubePlaylistStates[urlHash];
const ytSyncId = ytCancelState && ytCancelState.syncPlaylistId;
if (ytSyncId && _syncProgressCallbacks[ytSyncId]) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [ytSyncId] });
delete _syncProgressCallbacks[ytSyncId];
}
// Revert to discovered phase
updateYouTubeCardPhase(urlHash, 'discovered');
updateYouTubeModalButtons(urlHash, 'discovered');
showToast('YouTube sync cancelled', 'info');
} catch (error) {
console.error('β Error cancelling YouTube sync:', error);
showToast(`Error cancelling sync: ${error.message}`, 'error');
}
}
function updateYouTubeCardSyncProgress(urlHash, progress) {
const state = youtubePlaylistStates[urlHash];
if (!state || !state.cardElement || !progress) return;
const card = state.cardElement;
const progressElement = card.querySelector('.playlist-card-progress');
// Build clean status counter HTML exactly like Spotify cards
let statusCounterHTML = '';
if (progress && progress.total_tracks > 0) {
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const processed = matched + failed;
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
statusCounterHTML = `
βͺ ${total}
/
β ${matched}
/
β ${failed}
(${percentage}%)
`;
}
// Only update if we have valid sync progress, otherwise preserve existing discovery results
if (statusCounterHTML) {
progressElement.innerHTML = statusCounterHTML;
}
console.log(`π Updated YouTube sync progress: βͺ ${progress?.total_tracks || 0} / β ${progress?.matched_tracks || 0} / β ${progress?.failed_tracks || 0}`);
}
function updateYouTubeModalSyncProgress(urlHash, progress) {
// Try all source-specific element ID prefixes
const prefixes = ['youtube', 'listenbrainz', 'tidal', 'deezer', 'spotify-public', 'beatport'];
let statusDisplay = null;
let prefix = 'youtube';
for (const p of prefixes) {
statusDisplay = document.getElementById(`${p}-sync-status-${urlHash}`);
if (statusDisplay) { prefix = p; break; }
}
if (!statusDisplay || !progress) return;
const totalEl = document.getElementById(`${prefix}-total-${urlHash}`);
const matchedEl = document.getElementById(`${prefix}-matched-${urlHash}`);
const failedEl = document.getElementById(`${prefix}-failed-${urlHash}`);
const percentageEl = document.getElementById(`${prefix}-percentage-${urlHash}`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
// Calculate percentage like Spotify sync
if (total > 0) {
const processed = matched + failed;
const percentage = Math.round((processed / total) * 100);
if (percentageEl) percentageEl.textContent = percentage;
}
console.log(`π YouTube modal updated: βͺ ${total} / β ${matched} / β ${failed} (${Math.round((matched + failed) / total * 100)}%)`);
}
function updateYouTubeModalButtons(urlHash, phase) {
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (!modal) return;
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
}
}
// ===============================
// YOUTUBE DOWNLOAD MISSING TRACKS
// ===============================
async function startYouTubeDownloadMissing(urlHash) {
try {
console.log('π Starting download missing tracks:', urlHash);
// Check both YouTube and ListenBrainz states (like Beatport does)
const state = youtubePlaylistStates[urlHash] || listenbrainzPlaylistStates[urlHash];
// Support both camelCase and snake_case
const discoveryResults = state?.discoveryResults || state?.discovery_results;
if (!state || !discoveryResults) {
showToast('No discovery results available for download', 'error');
return;
}
// Determine source type (prefix removed - no longer needed)
const isListenBrainz = state.is_listenbrainz_playlist;
const isBeatport = state.is_beatport_playlist;
const isTidal = state.is_tidal_playlist;
const isDeezer = state.is_deezer_playlist;
// Convert discovery results to a format compatible with the download modal
const spotifyTracks = discoveryResults
.filter(result => result.spotify_data || (result.spotify_track && result.status_class === 'found'))
.map(result => {
if (result.spotify_data) {
return result.spotify_data;
} else {
// Build from individual fields (automatic discovery format)
// Convert album to proper object format for wishlist compatibility
const albumData = result.spotify_album || 'Unknown Album';
const albumObject = typeof albumData === 'object' && albumData !== null
? albumData
: {
name: typeof albumData === 'string' ? albumData : 'Unknown Album',
album_type: 'album',
images: []
};
return {
id: result.spotify_id || 'unknown',
name: result.spotify_track || 'Unknown Track',
artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'],
album: albumObject,
duration_ms: 0
};
}
});
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
// Create a virtual playlist for the download system
const virtualPlaylistId = isListenBrainz ? `listenbrainz_${urlHash}` : (isDeezer ? `deezer_${urlHash}` : (isBeatport ? `beatport_${urlHash}` : (isTidal ? `tidal_${urlHash}` : `youtube_${urlHash}`)));
const playlistName = state.playlist.name;
// Store reference for card navigation
state.convertedSpotifyPlaylistId = virtualPlaylistId;
// Close the discovery modal if it's open
const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (discoveryModal) {
discoveryModal.classList.add('hidden');
console.log('π Closed YouTube discovery modal to show download modal');
}
// Open download missing tracks modal for YouTube playlist
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
// Phase will change to 'downloading' when user clicks "Begin Analysis" button
} catch (error) {
console.error('β Error starting download missing tracks:', error);
showToast(`Error starting downloads: ${error.message}`, 'error');
}
}
async function resetYouTubePlaylist(urlHash) {
const state = youtubePlaylistStates[urlHash];
if (!state) return;
try {
console.log(`π Resetting YouTube playlist to fresh state: ${state.playlist.name}`);
// Call backend reset endpoint
const response = await fetch(`/api/youtube/reset/${urlHash}`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to reset playlist');
}
// Stop any active polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Update client state to match backend reset
state.phase = 'fresh';
state.discoveryResults = [];
state.discoveryProgress = 0;
state.spotifyMatches = 0;
state.syncPlaylistId = null;
state.syncProgress = {};
state.convertedSpotifyPlaylistId = null;
// Update card to reflect fresh state
updateYouTubeCardPhase(urlHash, 'fresh');
updateYouTubeCardProgress(urlHash, {
discovery_progress: 0,
spotify_matches: 0,
spotify_total: state.playlist.tracks.length
});
// Close modal
closeYouTubeDiscoveryModal(urlHash);
showToast(`Reset "${state.playlist.name}" to fresh state`, 'success');
console.log(`β
Successfully reset YouTube playlist: ${state.playlist.name}`);
} catch (error) {
console.error(`β Error resetting YouTube playlist:`, error);
showToast(`Error resetting playlist: ${error.message}`, 'error');
}
}
async function resetBeatportChart(urlHash) {
const state = youtubePlaylistStates[urlHash];
const chartState = beatportChartStates[urlHash];
if (!state || !state.is_beatport_playlist || !chartState) {
console.error('β Invalid Beatport chart state for reset');
return;
}
try {
console.log(`π Resetting Beatport chart to fresh state: ${state.playlist.name}`);
// Call backend reset endpoint for Beatport
const chartHash = state.beatport_chart_hash || urlHash;
const response = await fetch(`/api/beatport/charts/update-phase/${chartHash}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phase: 'fresh',
reset: true
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to reset Beatport chart');
}
// Stop any active polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Update client state to match backend reset
state.phase = 'fresh';
state.discoveryResults = [];
state.discoveryProgress = 0;
state.spotifyMatches = 0;
state.discovery_results = [];
state.discovery_progress = 0;
state.spotify_matches = 0;
state.syncPlaylistId = null;
state.syncProgress = {};
state.convertedSpotifyPlaylistId = null;
// Update Beatport chart state
chartState.phase = 'fresh';
// Update card to reflect fresh state
updateBeatportCardPhase(chartHash, 'fresh');
updateBeatportCardProgress(chartHash, {
spotify_total: state.playlist.tracks.length,
spotify_matches: 0,
failed: 0
});
// Close modal
closeYouTubeDiscoveryModal(urlHash);
showToast(`Reset "${state.playlist.name}" to fresh state`, 'success');
console.log(`β
Successfully reset Beatport chart: ${state.playlist.name}`);
} catch (error) {
console.error(`β Error resetting Beatport chart:`, error);
showToast(`Error resetting chart: ${error.message}`, 'error');
}
}
// ============================================================================
// LISTENBRAINZ PLAYLIST DISCOVERY & SYNC
// ============================================================================
function startListenBrainzDiscoveryPolling(playlistMbid) {
console.log(`π Starting ListenBrainz discovery polling for: ${playlistMbid}`);
// Stop any existing polling (reuse YouTube polling infrastructure)
if (activeYouTubePollers[playlistMbid]) {
clearInterval(activeYouTubePollers[playlistMbid]);
}
// Phase 5: Subscribe via WebSocket
if (socketConnected) {
socket.emit('discovery:subscribe', { ids: [playlistMbid] });
_discoveryProgressCallbacks[playlistMbid] = (data) => {
if (data.error) {
if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; }
socket.emit('discovery:unsubscribe', { ids: [playlistMbid] }); delete _discoveryProgressCallbacks[playlistMbid];
return;
}
if (listenbrainzPlaylistStates[playlistMbid]) {
const transformed = {
progress: data.progress || 0, spotify_matches: data.spotify_matches || 0, spotify_total: data.spotify_total || 0,
results: (data.results || []).map((r, i) => ({
index: r.index !== undefined ? r.index : i,
yt_track: r.lb_track || r.track_name || 'Unknown',
yt_artist: r.lb_artist || r.artist_name || 'Unknown',
status: (r.status === 'found' || r.status === 'β
Found' || r.status_class === 'found') ? 'β
Found' : (r.status === 'error' ? 'β Error' : 'β Not Found'),
status_class: r.status_class || ((r.status === 'found' || r.status === 'β
Found') ? 'found' : (r.status === 'error' ? 'error' : 'not-found')),
spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'),
spotify_artist: r.spotify_data ? (r.spotify_data.artists && r.spotify_data.artists[0] ? (typeof r.spotify_data.artists[0] === 'object' ? r.spotify_data.artists[0].name : r.spotify_data.artists[0]) : '-') : (r.spotify_artist || '-'),
spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) || '-' : (r.spotify_album || '-'),
spotify_data: r.spotify_data, duration: r.duration || '0:00'
})),
complete: data.complete || data.phase === 'discovered'
};
const st = listenbrainzPlaylistStates[playlistMbid];
st.discovery_results = data.results || []; st.discoveryResults = transformed.results;
st.discovery_progress = data.progress || 0; st.discoveryProgress = data.progress || 0;
st.spotify_matches = data.spotify_matches || 0; st.spotifyMatches = data.spotify_matches || 0;
st.spotify_total = data.spotify_total || 0; st.spotifyTotal = data.spotify_total || 0;
updateYouTubeDiscoveryModal(playlistMbid, transformed);
}
if (data.complete || data.phase === 'discovered') {
if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; }
socket.emit('discovery:unsubscribe', { ids: [playlistMbid] }); delete _discoveryProgressCallbacks[playlistMbid];
if (listenbrainzPlaylistStates[playlistMbid]) listenbrainzPlaylistStates[playlistMbid].phase = 'discovered';
updateYouTubeModalButtons(playlistMbid, 'discovered');
const _descElWs = document.querySelector(`#youtube-discovery-modal-${playlistMbid} .modal-description`);
if (_descElWs) _descElWs.textContent = 'Discovery complete! View the results below.';
const playlistIdEl = `discover-lb-playlist-${playlistMbid}`;
const syncBtn = document.getElementById(`${playlistIdEl}-sync-btn`);
if (syncBtn) syncBtn.style.display = 'inline-block';
showToast('ListenBrainz discovery complete!', 'success');
}
};
}
const pollInterval = setInterval(async () => {
// Always poll β no dedicated WebSocket events for discovery progress
try {
const response = await fetch(`/api/listenbrainz/discovery/status/${playlistMbid}`);
const status = await response.json();
if (status.error) {
console.error('β Error polling ListenBrainz discovery status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
return;
}
// Update state and modal (reuse YouTube infrastructure like Beatport/Tidal)
if (listenbrainzPlaylistStates[playlistMbid]) {
// Transform ListenBrainz results to YouTube modal format (like Beatport does)
const transformedStatus = {
progress: status.progress || 0,
spotify_matches: status.spotify_matches || 0,
spotify_total: status.spotify_total || 0,
results: (status.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 && result.spotify_data.artists[0] ? (typeof result.spotify_data.artists[0] === 'object' ? result.spotify_data.artists[0].name : result.spotify_data.artists[0]) : '-') : (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'
})),
complete: status.complete || status.phase === 'discovered'
};
// Store both raw and transformed results (support both naming conventions)
listenbrainzPlaylistStates[playlistMbid].discovery_results = status.results || [];
listenbrainzPlaylistStates[playlistMbid].discoveryResults = transformedStatus.results;
listenbrainzPlaylistStates[playlistMbid].discovery_progress = status.progress || 0;
listenbrainzPlaylistStates[playlistMbid].discoveryProgress = status.progress || 0;
listenbrainzPlaylistStates[playlistMbid].spotify_matches = status.spotify_matches || 0;
listenbrainzPlaylistStates[playlistMbid].spotifyMatches = status.spotify_matches || 0; // camelCase for modal
listenbrainzPlaylistStates[playlistMbid].spotify_total = status.spotify_total || 0;
listenbrainzPlaylistStates[playlistMbid].spotifyTotal = status.spotify_total || 0; // camelCase for modal
// Update modal if open
updateYouTubeDiscoveryModal(playlistMbid, transformedStatus);
}
// Check if complete
if (status.complete || status.phase === 'discovered') {
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
// Update phase in backend for persistence (like Beatport does)
try {
await fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phase: 'discovered' })
});
console.log('β
Updated ListenBrainz backend phase to discovered');
} catch (error) {
console.warn('β οΈ Failed to update backend phase:', error);
}
// Update phase in frontend state
if (listenbrainzPlaylistStates[playlistMbid]) {
listenbrainzPlaylistStates[playlistMbid].phase = 'discovered';
}
// Update modal buttons to show sync and download buttons
updateYouTubeModalButtons(playlistMbid, 'discovered');
// Update modal description to "Discovery complete!"
const descEl = document.querySelector(`#youtube-discovery-modal-${playlistMbid} .modal-description`);
if (descEl) descEl.textContent = 'Discovery complete! View the results below.';
// Show sync button in playlist listing (hidden by default until discovered)
const playlistId = `discover-lb-playlist-${playlistMbid}`;
const syncBtn = document.getElementById(`${playlistId}-sync-btn`);
if (syncBtn) {
syncBtn.style.display = 'inline-block';
console.log('β
Showing sync button after discovery completion');
}
console.log('β
ListenBrainz discovery complete:', playlistMbid);
showToast('ListenBrainz discovery complete!', 'success');
}
} catch (error) {
console.error('β Error polling ListenBrainz discovery:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
}
}, 1000);
activeYouTubePollers[playlistMbid] = pollInterval;
}
function startListenBrainzSyncPolling(playlistMbid, syncPlaylistId) {
// Stop any existing polling
if (activeYouTubePollers[playlistMbid]) {
clearInterval(activeYouTubePollers[playlistMbid]);
}
// Resolve syncPlaylistId from argument or stored state
const lbState = listenbrainzPlaylistStates[playlistMbid];
syncPlaylistId = syncPlaylistId || (lbState && lbState.syncPlaylistId);
// Phase 6: Subscribe via WebSocket
if (socketConnected && syncPlaylistId) {
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
_syncProgressCallbacks[syncPlaylistId] = (data) => {
const progress = data.progress || {};
updateYouTubeModalSyncProgress(playlistMbid, progress);
if (data.status === 'finished') {
if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
updateYouTubeModalButtons(playlistMbid, 'sync_complete');
showToast('ListenBrainz playlist sync complete!', 'success');
} else if (data.status === 'error' || data.status === 'cancelled') {
if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
updateYouTubeModalButtons(playlistMbid, 'discovered');
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
}
};
}
// Define the polling function (HTTP fallback)
const pollFunction = async () => {
if (socketConnected) return; // Phase 6: WS handles updates
try {
const response = await fetch(`/api/listenbrainz/sync/status/${playlistMbid}`);
const status = await response.json();
if (status.error) {
console.error('β Error polling ListenBrainz sync status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
return;
}
updateYouTubeModalSyncProgress(playlistMbid, status.progress);
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
updateYouTubeModalButtons(playlistMbid, 'sync_complete');
showToast('ListenBrainz playlist sync complete!', 'success');
} else if (status.sync_status === 'error') {
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
updateYouTubeModalButtons(playlistMbid, 'discovered');
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('β Error polling ListenBrainz sync:', error);
if (activeYouTubePollers[playlistMbid]) {
clearInterval(activeYouTubePollers[playlistMbid]);
delete activeYouTubePollers[playlistMbid];
}
}
};
// Run immediately to get current status (skip if WS active)
if (!socketConnected) pollFunction();
// Then continue polling at regular intervals
const pollInterval = setInterval(pollFunction, 1000);
activeYouTubePollers[playlistMbid] = pollInterval;
}
async function startListenBrainzDiscovery(playlistMbid) {
const state = listenbrainzPlaylistStates[playlistMbid];
if (!state) {
console.error('β No ListenBrainz playlist state found');
return;
}
try {
console.log('π Starting ListenBrainz discovery for:', state.playlist.name);
// Update local phase to discovering
state.phase = 'discovering';
state.status = 'discovering';
// Call backend to start discovery worker
const response = await fetch(`/api/listenbrainz/discovery/start/${playlistMbid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist: state.playlist
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to start discovery');
}
console.log('β
ListenBrainz discovery started on backend');
// Start polling for progress
startListenBrainzDiscoveryPolling(playlistMbid);
// Update modal to show discovering state
updateYouTubeDiscoveryModal(playlistMbid, {
phase: 'discovering',
progress: 0,
results: []
});
showToast('Starting ListenBrainz discovery...', 'info');
} catch (error) {
console.error('β Error starting ListenBrainz discovery:', error);
showToast(`Error: ${error.message}`, 'error');
// Revert phase on error
state.phase = 'fresh';
state.status = 'pending';
}
}
async function startListenBrainzPlaylistSync(playlistMbid) {
const state = listenbrainzPlaylistStates[playlistMbid];
if (!state) {
console.error('β No ListenBrainz playlist state found');
return;
}
try {
console.log('π Starting ListenBrainz sync for:', state.playlist.name);
// Check if being called from playlist listing (has UI elements) or modal
const listingPlaylistId = `discover-lb-playlist-${playlistMbid}`;
const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`);
const isFromListing = statusDisplay !== null;
if (isFromListing) {
console.log('π Sync initiated from playlist listing');
// Show status display in listing
statusDisplay.style.display = 'block';
const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`);
if (syncButton) {
syncButton.disabled = true;
syncButton.style.opacity = '0.5';
}
}
// Call backend to start sync
const response = await fetch(`/api/listenbrainz/sync/start/${playlistMbid}`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to start sync');
}
// Capture sync_playlist_id for WebSocket subscription
const result = await response.json();
const syncPlaylistId = result.sync_playlist_id;
if (state) state.syncPlaylistId = syncPlaylistId;
// Update phase to syncing
state.phase = 'syncing';
// Start polling for sync progress
if (isFromListing) {
startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId, syncPlaylistId);
} else {
startListenBrainzSyncPolling(playlistMbid, syncPlaylistId);
updateYouTubeModalButtons(playlistMbid, 'syncing');
}
showToast('Starting ListenBrainz sync...', 'info');
} catch (error) {
console.error('β Error starting ListenBrainz sync:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
function startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId, syncPlaylistId) {
console.log(`π Starting listing sync polling for: ${playlistMbid} (UI: ${listingPlaylistId})`);
// Stop any existing polling
if (activeYouTubePollers[playlistMbid]) {
clearInterval(activeYouTubePollers[playlistMbid]);
}
// Resolve syncPlaylistId from argument or stored state
const lbState = listenbrainzPlaylistStates[playlistMbid];
syncPlaylistId = syncPlaylistId || (lbState && lbState.syncPlaylistId);
// Phase 6: Subscribe via WebSocket
if (socketConnected && syncPlaylistId) {
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
_syncProgressCallbacks[syncPlaylistId] = (data) => {
const progress = data.progress || {};
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const percentage = total > 0 ? Math.round((matched / total) * 100) : 0;
const totalEl = document.getElementById(`${listingPlaylistId}-sync-total`);
const matchedEl = document.getElementById(`${listingPlaylistId}-sync-matched`);
const failedEl = document.getElementById(`${listingPlaylistId}-sync-failed`);
const percentageEl = document.getElementById(`${listingPlaylistId}-sync-percentage`);
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
if (percentageEl) percentageEl.textContent = percentage;
if (data.status === 'finished') {
if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`);
const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`);
if (statusDisplay) setTimeout(() => { statusDisplay.style.display = 'none'; }, 3000);
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; }
if (listenbrainzPlaylistStates[playlistMbid]) {
listenbrainzPlaylistStates[playlistMbid].phase = 'sync_complete';
}
showToast(`Sync complete: ${matched}/${total} tracks matched`, 'success');
} else if (data.status === 'error' || data.status === 'cancelled') {
if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; }
socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
}
};
}
const pollInterval = setInterval(async () => {
if (socketConnected) return; // Phase 6: WS handles updates
try {
const response = await fetch(`/api/listenbrainz/sync/status/${playlistMbid}`);
const status = await response.json();
if (status.error) {
console.error('β Error polling ListenBrainz sync status:', status.error);
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
return;
}
const totalEl = document.getElementById(`${listingPlaylistId}-sync-total`);
const matchedEl = document.getElementById(`${listingPlaylistId}-sync-matched`);
const failedEl = document.getElementById(`${listingPlaylistId}-sync-failed`);
const percentageEl = document.getElementById(`${listingPlaylistId}-sync-percentage`);
if (totalEl) totalEl.textContent = status.progress?.total_tracks || 0;
if (matchedEl) matchedEl.textContent = status.progress?.matched_tracks || 0;
if (failedEl) failedEl.textContent = status.progress?.failed_tracks || 0;
const percentage = status.progress?.total_tracks > 0
? Math.round(((status.progress?.matched_tracks || 0) / status.progress.total_tracks) * 100)
: 0;
if (percentageEl) percentageEl.textContent = percentage;
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`);
const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`);
if (statusDisplay) setTimeout(() => { statusDisplay.style.display = 'none'; }, 3000);
if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; }
if (listenbrainzPlaylistStates[playlistMbid]) {
listenbrainzPlaylistStates[playlistMbid].phase = 'sync_complete';
}
showToast(`Sync complete: ${status.progress?.matched_tracks || 0}/${status.progress?.total_tracks || 0} tracks matched`, 'success');
}
} catch (error) {
console.error('β Error polling ListenBrainz listing sync:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[playlistMbid];
}
}, 1000);
activeYouTubePollers[playlistMbid] = pollInterval;
}
// ============================================================================