Last of the three unified-tab phases. Surfaces the user's persisted personalized playlists (decade mixes, hidden gems, popular picks, daily mixes, discovery shuffle, etc.) on the Sync page so they participate in the mirrored-playlist + Auto-Sync pipeline like every other source. Different shape from the LB / Last.fm tabs: - Tracks already carry Spotify / iTunes / Deezer IDs (matched at generation time from the discovery pool), so there is NO MB-style "needs discovery" hop. The mirror is created with fully-populated ``matched_data`` JSON inline, downstream consumers (sync, wishlist) see canonical extra_data immediately. - Click on a card runs the kind's generator (``POST /api/personalized/playlist/<kind>/<variant>/refresh``) + grabs the fresh track snapshot + mirrors under a synthetic id of the form ``ssd_<kind>_<variant>`` (e.g. ``ssd_decade_1980s``, ``ssd_hidden_gems``). Re-clicks UPSERT the same row, so the Auto-Sync schedule survives every refresh. - Sub-tabs / archive concept don't apply here — each personalized playlist is already a singleton per (profile, kind, variant); the manager handles its own rotation. New file: ``webui/static/sync-soulsync-discovery.js`` (~210 lines). ``initializeSyncPage`` learns a new tab branch. CSS adds ``soulsync-discovery-icon`` (star SVG, teal ``#14b8a6``) + ``.soulsync-discovery-playlist-card`` joins the unified card selector group with a matching teal accent. WHATS_NEW entry added under 2.6.3. 236 tests still green; no Python paths touched.
11392 lines
518 KiB
JavaScript
11392 lines
518 KiB
JavaScript
// 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 = `<div class="playlist-placeholder">🔄 Loading Tidal playlists...</div>`;
|
||
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 = `<div class="playlist-placeholder">❌ Error: ${error.message}</div>`;
|
||
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 = `<div class="playlist-placeholder">No Tidal playlists found.</div>`;
|
||
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 `
|
||
<div class="youtube-playlist-card tidal-playlist-card" id="tidal-card-${playlist.id}">
|
||
<div class="playlist-card-icon">🎵</div>
|
||
<div class="playlist-card-content">
|
||
<div class="playlist-card-name">${escapeHtml(playlist.name)}</div>
|
||
<div class="playlist-card-info">
|
||
<span class="playlist-card-track-count">${playlist.track_count} tracks</span>
|
||
<span class="playlist-card-phase-text" style="color: ${phaseColor};">${phaseText}</span>
|
||
</div>
|
||
</div>
|
||
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}">
|
||
<!-- Progress will be dynamically updated based on phase -->
|
||
</div>
|
||
<button class="playlist-card-action-btn">${buttonText}</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="playlist-card-sync-status">
|
||
<span class="sync-stat total-tracks">♪ ${total}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ ${matched}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ ${failed}</span>
|
||
<span class="sync-stat percentage">(${percentage}%)</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 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('qobuz_') ? 'Qobuz' :
|
||
virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' :
|
||
virtualPlaylistId.startsWith('spotify_public_') ? 'Spotify' :
|
||
virtualPlaylistId.startsWith('itunes_link_') ? 'iTunes' :
|
||
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 = `
|
||
<div class="download-missing-modal-content" data-context="playlist">
|
||
<div class="download-missing-modal-header">
|
||
${generateDownloadModalHeroSection(heroContext)}
|
||
</div>
|
||
|
||
<div class="download-missing-modal-body">
|
||
<div class="download-progress-section">
|
||
<div class="progress-item">
|
||
<div class="progress-label">
|
||
🔍 Library Analysis
|
||
<span id="analysis-progress-text-${virtualPlaylistId}">Ready to start</span>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill analysis" id="analysis-progress-fill-${virtualPlaylistId}"></div>
|
||
</div>
|
||
</div>
|
||
<div class="progress-item">
|
||
<div class="progress-label">
|
||
⏬ Downloads
|
||
<span id="download-progress-text-${virtualPlaylistId}">Waiting for analysis</span>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill download" id="download-progress-fill-${virtualPlaylistId}"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-tracks-section">
|
||
<div class="download-tracks-header">
|
||
<h3 class="download-tracks-title">📋 Track Analysis & Download Status</h3>
|
||
<span class="track-selection-count" id="track-selection-count-${virtualPlaylistId}">${spotifyTracks.length} / ${spotifyTracks.length} tracks selected</span>
|
||
</div>
|
||
<div class="download-tracks-table-container">
|
||
<table class="download-tracks-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="track-select-header">
|
||
<input type="checkbox" class="track-select-all"
|
||
id="select-all-${virtualPlaylistId}" checked
|
||
onchange="toggleAllTrackSelections('${virtualPlaylistId}', this.checked)">
|
||
</th>
|
||
<th>#</th>
|
||
<th>Track</th>
|
||
<th>Artist</th>
|
||
<th>Duration</th>
|
||
<th>Library Match</th>
|
||
<th>Download Status</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="download-tracks-tbody-${virtualPlaylistId}">
|
||
${spotifyTracks.map((track, index) => `
|
||
<tr data-track-index="${index}">
|
||
<td class="track-select-cell">
|
||
<input type="checkbox" class="track-select-cb"
|
||
data-track-index="${index}" checked
|
||
onchange="updateTrackSelectionCount('${virtualPlaylistId}')">
|
||
</td>
|
||
<td class="track-number">${index + 1}</td>
|
||
<td class="track-name" title="${escapeHtml(track.name)}">${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)}</td>
|
||
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
|
||
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
|
||
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
|
||
<td class="track-download-status" id="download-${virtualPlaylistId}-${index}">-</td>
|
||
<td class="track-actions" id="actions-${virtualPlaylistId}-${index}">-</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-missing-modal-footer">
|
||
<div class="download-phase-controls">
|
||
<div class="force-download-toggle-container" style="margin-bottom: 0px; display: flex; flex-direction: column; gap: 8px; align-items: flex-start;">
|
||
<label class="force-download-toggle">
|
||
<input type="checkbox" id="force-download-all-${virtualPlaylistId}">
|
||
<span>Force Download All</span>
|
||
</label>
|
||
<label class="force-download-toggle">
|
||
<input type="checkbox" id="playlist-folder-mode-${virtualPlaylistId}">
|
||
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span>
|
||
</label>
|
||
</div>
|
||
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
|
||
Begin Analysis
|
||
</button>
|
||
<button class="download-control-btn" id="add-to-wishlist-btn-${virtualPlaylistId}" onclick="addModalTracksToWishlist('${virtualPlaylistId}')" style="background-color: #9333ea; color: white;">
|
||
Add to Wishlist
|
||
</button>
|
||
<button class="download-control-btn danger" id="cancel-all-btn-${virtualPlaylistId}" onclick="cancelAllOperations('${virtualPlaylistId}')" style="display: none;">
|
||
Cancel All
|
||
</button>
|
||
</div>
|
||
<div class="modal-close-section">
|
||
<button class="download-control-btn secondary" onclick="closeDownloadMissingModal('${virtualPlaylistId}')">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length);
|
||
modal.style.display = 'flex';
|
||
hideLoadingOverlay();
|
||
}
|
||
|
||
|
||
// ===================================================================
|
||
// QOBUZ PLAYLIST MANAGEMENT (mirrors Tidal — github issue #677)
|
||
// ===================================================================
|
||
//
|
||
// Functional parity with the Tidal sync flow above. Backend at
|
||
// /api/qobuz/* mirrors /api/tidal/* one-for-one, so the handler shape
|
||
// is intentionally a direct port of the Tidal set. Per-card key in
|
||
// CSS selectors is `qobuz-card-${id}` (no colon — same constraint as
|
||
// Tidal: `:` parses as a CSS pseudo-class operator).
|
||
|
||
async function loadQobuzPlaylists() {
|
||
const container = document.getElementById('qobuz-playlist-container');
|
||
const refreshBtn = document.getElementById('qobuz-refresh-btn');
|
||
|
||
container.innerHTML = `<div class="playlist-placeholder">🔄 Loading Qobuz playlists...</div>`;
|
||
refreshBtn.disabled = true;
|
||
refreshBtn.textContent = '🔄 Loading...';
|
||
|
||
try {
|
||
const response = await fetch('/api/qobuz/playlists');
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || 'Failed to fetch Qobuz playlists');
|
||
}
|
||
|
||
qobuzPlaylists = await response.json();
|
||
renderQobuzPlaylists();
|
||
qobuzPlaylistsLoaded = true;
|
||
|
||
console.log(`🎵 Loaded ${qobuzPlaylists.length} Qobuz playlists`);
|
||
|
||
// Auto-mirror Qobuz playlists in the background (same pattern as Tidal).
|
||
// Cards render instantly from metadata; tracks load per-playlist
|
||
// without blocking the UI.
|
||
for (const p of qobuzPlaylists) {
|
||
if (p.tracks && p.tracks.length > 0) {
|
||
mirrorPlaylist('qobuz', 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;
|
||
}
|
||
try {
|
||
const fullResp = await fetch(`/api/qobuz/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;
|
||
const countEl = document.querySelector(`#qobuz-card-${p.id} .playlist-card-track-count`);
|
||
if (countEl) countEl.textContent = `${fullData.tracks.length} tracks`;
|
||
mirrorPlaylist('qobuz', 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 Qobuz playlist ${p.name}: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
await loadQobuzPlaylistStatesFromBackend();
|
||
|
||
} catch (error) {
|
||
container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${error.message}</div>`;
|
||
showToast(`Error loading Qobuz playlists: ${error.message}`, 'error');
|
||
} finally {
|
||
refreshBtn.disabled = false;
|
||
refreshBtn.textContent = '🔄 Refresh';
|
||
}
|
||
}
|
||
|
||
function renderQobuzPlaylists() {
|
||
const container = document.getElementById('qobuz-playlist-container');
|
||
if (qobuzPlaylists.length === 0) {
|
||
container.innerHTML = `<div class="playlist-placeholder">No Qobuz playlists found.</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = qobuzPlaylists.map(p => {
|
||
if (!qobuzPlaylistStates[p.id]) {
|
||
qobuzPlaylistStates[p.id] = {
|
||
phase: 'fresh',
|
||
playlist: p
|
||
};
|
||
}
|
||
return createQobuzCard(p);
|
||
}).join('');
|
||
|
||
qobuzPlaylists.forEach(p => {
|
||
const card = document.getElementById(`qobuz-card-${p.id}`);
|
||
if (card) {
|
||
card.addEventListener('click', () => handleQobuzCardClick(p.id));
|
||
}
|
||
});
|
||
}
|
||
|
||
function createQobuzCard(playlist) {
|
||
const state = qobuzPlaylistStates[playlist.id];
|
||
const phase = state.phase;
|
||
|
||
let buttonText = getActionButtonText(phase);
|
||
let phaseText = getPhaseText(phase);
|
||
let phaseColor = getPhaseColor(phase);
|
||
|
||
return `
|
||
<div class="youtube-playlist-card qobuz-playlist-card" id="qobuz-card-${playlist.id}">
|
||
<div class="playlist-card-icon">🎵</div>
|
||
<div class="playlist-card-content">
|
||
<div class="playlist-card-name">${escapeHtml(playlist.name)}</div>
|
||
<div class="playlist-card-info">
|
||
<span class="playlist-card-track-count">${playlist.track_count} tracks</span>
|
||
<span class="playlist-card-phase-text" style="color: ${phaseColor};">${phaseText}</span>
|
||
</div>
|
||
</div>
|
||
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}">
|
||
<!-- Progress will be dynamically updated based on phase -->
|
||
</div>
|
||
<button class="playlist-card-action-btn">${buttonText}</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function handleQobuzCardClick(playlistId) {
|
||
const state = qobuzPlaylistStates[playlistId];
|
||
if (!state) {
|
||
console.error(`❌ [Card Click] No state found for Qobuz playlist: ${playlistId}`);
|
||
showToast('Playlist state not found - try refreshing the page', 'error');
|
||
return;
|
||
}
|
||
if (!state.playlist) {
|
||
console.error(`❌ [Card Click] No playlist data found for Qobuz playlist: ${playlistId}`);
|
||
showToast('Playlist data missing - try refreshing the page', 'error');
|
||
return;
|
||
}
|
||
if (!state.phase) state.phase = 'fresh';
|
||
|
||
console.log(`🎵 [Card Click] Qobuz card clicked: ${playlistId}, Phase: ${state.phase}`);
|
||
|
||
if (state.phase === 'fresh') {
|
||
if (!state.playlist.tracks || state.playlist.tracks.length === 0) {
|
||
console.log(`🎵 Fetching tracks for Qobuz playlist: ${state.playlist.name}`);
|
||
showLoadingOverlay(`Loading ${state.playlist.name}...`);
|
||
try {
|
||
const resp = await fetch(`/api/qobuz/playlist/${playlistId}`);
|
||
if (resp.ok) {
|
||
const fullData = await resp.json();
|
||
if (fullData.tracks && fullData.tracks.length > 0) {
|
||
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
|
||
}));
|
||
const countEl = document.querySelector(`#qobuz-card-${playlistId} .playlist-card-track-count`);
|
||
if (countEl) countEl.textContent = `${state.playlist.tracks.length} tracks`;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(`Failed to fetch Qobuz 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} Qobuz tracks for discovery`);
|
||
openQobuzDiscoveryModal(playlistId, state.playlist);
|
||
|
||
} else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') {
|
||
console.log(`🎵 [Card Click] Opening Qobuz discovery modal for ${state.phase} phase`);
|
||
|
||
if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) {
|
||
try {
|
||
const stateResponse = await fetch(`/api/qobuz/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;
|
||
qobuzPlaylistStates[playlistId] = { ...qobuzPlaylistStates[playlistId], ...state };
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ [Card Click] Failed to fetch Qobuz discovery results: ${error}`);
|
||
}
|
||
}
|
||
|
||
openQobuzDiscoveryModal(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 rehydrateQobuzDownloadModal(playlistId, state);
|
||
}
|
||
} else {
|
||
await rehydrateQobuzDownloadModal(playlistId, state);
|
||
}
|
||
} else {
|
||
if (state.discovery_results && state.discovery_results.length > 0) {
|
||
openQobuzDiscoveryModal(playlistId, state.playlist);
|
||
} else {
|
||
showToast('Unable to open download modal - missing playlist data', 'error');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function rehydrateQobuzDownloadModal(playlistId, state) {
|
||
try {
|
||
if (!state || !state.playlist) {
|
||
showToast('Cannot open download modal - invalid playlist data', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!state.discovery_results) {
|
||
const stateResponse = await fetch(`/api/qobuz/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;
|
||
} else {
|
||
showToast('Error loading playlist data', 'error');
|
||
return;
|
||
}
|
||
}
|
||
|
||
const spotifyTracks = [];
|
||
for (const result of state.discovery_results) {
|
||
if (result.spotify_data) spotifyTracks.push(result.spotify_data);
|
||
}
|
||
|
||
if (spotifyTracks.length === 0) {
|
||
showToast('No Spotify matches found for download', 'error');
|
||
return;
|
||
}
|
||
|
||
const virtualPlaylistId = state.convertedSpotifyPlaylistId;
|
||
const playlistName = state.playlist.name;
|
||
|
||
await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks);
|
||
|
||
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;
|
||
|
||
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';
|
||
startModalDownloadPolling(virtualPlaylistId);
|
||
} else if (state.phase === 'download_complete') {
|
||
if (beginBtn) beginBtn.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'none';
|
||
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) {
|
||
updateCompletedModalResults(virtualPlaylistId, data);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
showToast('Could not load download results - modal may show incomplete data', 'warning', 3000);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ Error rehydrating Qobuz download modal:`, error);
|
||
showToast('Error opening download modal', 'error');
|
||
}
|
||
}
|
||
|
||
function updateQobuzCardPhase(playlistId, phase) {
|
||
const state = qobuzPlaylistStates[playlistId];
|
||
if (!state) return;
|
||
|
||
state.phase = phase;
|
||
|
||
const card = document.getElementById(`qobuz-card-${playlistId}`);
|
||
if (card) {
|
||
const newCardHtml = createQobuzCard(state.playlist);
|
||
card.outerHTML = newCardHtml;
|
||
|
||
const newCard = document.getElementById(`qobuz-card-${playlistId}`);
|
||
if (newCard) {
|
||
newCard.addEventListener('click', () => handleQobuzCardClick(playlistId));
|
||
}
|
||
|
||
if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) {
|
||
setTimeout(() => {
|
||
updateQobuzCardSyncProgress(playlistId, state.lastSyncProgress);
|
||
}, 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function openQobuzDiscoveryModal(playlistId, playlistData) {
|
||
console.log(`🎵 Opening Qobuz discovery modal: ${playlistData.name}`);
|
||
const fakeUrlHash = `qobuz_${playlistId}`;
|
||
|
||
const qobuzCardState = qobuzPlaylistStates[playlistId];
|
||
const isAlreadyDiscovered = qobuzCardState && (qobuzCardState.phase === 'discovered' || qobuzCardState.phase === 'syncing' || qobuzCardState.phase === 'sync_complete');
|
||
const isCurrentlyDiscovering = qobuzCardState && qobuzCardState.phase === 'discovering';
|
||
|
||
let transformedResults = [];
|
||
let actualMatches = 0;
|
||
if (isAlreadyDiscovered && qobuzCardState.discovery_results) {
|
||
transformedResults = qobuzCardState.discovery_results.map((result, index) => {
|
||
const isFound = result.status === 'found' ||
|
||
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.qobuz_track ? result.qobuz_track.name : 'Unknown',
|
||
yt_artist: result.qobuz_track ? (result.qobuz_track.artists ? result.qobuz_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 modalPhase = qobuzCardState ? qobuzCardState.phase : 'fresh';
|
||
youtubePlaylistStates[fakeUrlHash] = {
|
||
phase: modalPhase,
|
||
playlist: { name: playlistData.name, tracks: playlistData.tracks },
|
||
is_qobuz_playlist: true,
|
||
qobuz_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 {
|
||
const response = await fetch(`/api/qobuz/discovery/start/${playlistId}`, { method: 'POST' });
|
||
const result = await response.json();
|
||
if (result.error) {
|
||
showToast(`Error starting discovery: ${result.error}`, 'error');
|
||
return;
|
||
}
|
||
qobuzPlaylistStates[playlistId].phase = 'discovering';
|
||
updateQobuzCardPhase(playlistId, 'discovering');
|
||
youtubePlaylistStates[fakeUrlHash].phase = 'discovering';
|
||
startQobuzDiscoveryPolling(fakeUrlHash, playlistId);
|
||
} catch (error) {
|
||
showToast(`Error starting discovery: ${error.message}`, 'error');
|
||
}
|
||
} else if (isCurrentlyDiscovering) {
|
||
startQobuzDiscoveryPolling(fakeUrlHash, playlistId);
|
||
} else if (qobuzCardState && qobuzCardState.phase === 'syncing') {
|
||
startQobuzSyncPolling(fakeUrlHash);
|
||
}
|
||
|
||
openYouTubeDiscoveryModal(fakeUrlHash);
|
||
}
|
||
|
||
function startQobuzDiscoveryPolling(fakeUrlHash, playlistId) {
|
||
if (activeYouTubePollers[fakeUrlHash]) {
|
||
clearInterval(activeYouTubePollers[fakeUrlHash]);
|
||
}
|
||
|
||
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 === 'Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track);
|
||
return {
|
||
index: i, yt_track: r.qobuz_track ? r.qobuz_track.name : 'Unknown',
|
||
yt_artist: r.qobuz_track ? (r.qobuz_track.artists ? r.qobuz_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 (qobuzPlaylistStates[playlistId]) {
|
||
qobuzPlaylistStates[playlistId].phase = data.phase;
|
||
qobuzPlaylistStates[playlistId].discovery_results = data.results;
|
||
qobuzPlaylistStates[playlistId].spotify_matches = data.spotify_matches;
|
||
qobuzPlaylistStates[playlistId].discovery_progress = data.progress;
|
||
updateQobuzCardPhase(playlistId, data.phase);
|
||
}
|
||
updateQobuzCardProgress(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 () => {
|
||
try {
|
||
const response = await fetch(`/api/qobuz/discovery/status/${playlistId}`);
|
||
const status = await response.json();
|
||
if (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 === 'Found' ||
|
||
result.status_class === 'found' ||
|
||
result.spotify_data ||
|
||
result.spotify_track;
|
||
return {
|
||
index: index,
|
||
yt_track: result.qobuz_track ? result.qobuz_track.name : 'Unknown',
|
||
yt_artist: result.qobuz_track ? (result.qobuz_track.artists ? result.qobuz_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 (qobuzPlaylistStates[playlistId]) {
|
||
qobuzPlaylistStates[playlistId].phase = status.phase;
|
||
qobuzPlaylistStates[playlistId].discovery_results = status.results;
|
||
qobuzPlaylistStates[playlistId].spotify_matches = status.spotify_matches;
|
||
qobuzPlaylistStates[playlistId].discovery_progress = status.progress;
|
||
updateQobuzCardPhase(playlistId, status.phase);
|
||
}
|
||
updateQobuzCardProgress(playlistId, status);
|
||
}
|
||
|
||
if (status.complete) {
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[fakeUrlHash];
|
||
}
|
||
} catch (error) {
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[fakeUrlHash];
|
||
}
|
||
}, 1000);
|
||
|
||
activeYouTubePollers[fakeUrlHash] = pollInterval;
|
||
}
|
||
|
||
async function loadQobuzPlaylistStatesFromBackend() {
|
||
try {
|
||
const response = await fetch('/api/qobuz/playlists/states');
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || 'Failed to fetch Qobuz playlist states');
|
||
}
|
||
|
||
const data = await response.json();
|
||
const states = data.states || [];
|
||
if (states.length === 0) return;
|
||
|
||
for (const stateInfo of states) {
|
||
await applyQobuzPlaylistState(stateInfo);
|
||
}
|
||
|
||
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]) {
|
||
try {
|
||
const playlistData = qobuzPlaylists.find(p => p.id === stateInfo.playlist_id);
|
||
if (!playlistData) continue;
|
||
|
||
const spotifyTracks = qobuzPlaylistStates[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 Qobuz download modal for ${stateInfo.playlist_id}:`, error);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ Error loading Qobuz playlist states:', error);
|
||
}
|
||
}
|
||
|
||
async function applyQobuzPlaylistState(stateInfo) {
|
||
const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo;
|
||
|
||
try {
|
||
const playlistData = qobuzPlaylists.find(p => p.id === playlist_id);
|
||
if (!playlistData) return;
|
||
|
||
if (!qobuzPlaylistStates[playlist_id]) {
|
||
qobuzPlaylistStates[playlist_id] = { playlist: playlistData, phase: 'fresh' };
|
||
}
|
||
|
||
qobuzPlaylistStates[playlist_id].phase = phase;
|
||
qobuzPlaylistStates[playlist_id].discovery_progress = discovery_progress;
|
||
qobuzPlaylistStates[playlist_id].spotify_matches = spotify_matches;
|
||
qobuzPlaylistStates[playlist_id].discovery_results = discovery_results;
|
||
qobuzPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id;
|
||
qobuzPlaylistStates[playlist_id].download_process_id = download_process_id;
|
||
qobuzPlaylistStates[playlist_id].playlist = playlistData;
|
||
|
||
if (phase !== 'fresh' && phase !== 'discovering') {
|
||
try {
|
||
const stateResponse = await fetch(`/api/qobuz/state/${playlist_id}`);
|
||
if (stateResponse.ok) {
|
||
const fullState = await stateResponse.json();
|
||
if (fullState.discovery_results && qobuzPlaylistStates[playlist_id]) {
|
||
qobuzPlaylistStates[playlist_id].discovery_results = fullState.discovery_results;
|
||
qobuzPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress;
|
||
qobuzPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches;
|
||
qobuzPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
|
||
qobuzPlaylistStates[playlist_id].download_process_id = fullState.download_process_id;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn(`⚠️ Error fetching full Qobuz discovery results for ${playlistData.name}:`, error.message);
|
||
}
|
||
}
|
||
|
||
updateQobuzCardPhase(playlist_id, phase);
|
||
|
||
if (phase === 'discovered' && qobuzPlaylistStates[playlist_id]) {
|
||
const progressInfo = {
|
||
spotify_total: playlistData.track_count || playlistData.tracks?.length || 0,
|
||
spotify_matches: qobuzPlaylistStates[playlist_id].spotify_matches || 0
|
||
};
|
||
updateQobuzCardProgress(playlist_id, progressInfo);
|
||
}
|
||
|
||
if (phase === 'discovering') {
|
||
const fakeUrlHash = `qobuz_${playlist_id}`;
|
||
startQobuzDiscoveryPolling(fakeUrlHash, playlist_id);
|
||
} else if (phase === 'syncing') {
|
||
const fakeUrlHash = `qobuz_${playlist_id}`;
|
||
startQobuzSyncPolling(fakeUrlHash);
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ Error applying Qobuz playlist state for ${playlist_id}:`, error);
|
||
}
|
||
}
|
||
|
||
function updateQobuzCardProgress(playlistId, progress) {
|
||
const state = qobuzPlaylistStates[playlistId];
|
||
if (!state) return;
|
||
const card = document.getElementById(`qobuz-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');
|
||
}
|
||
|
||
async function startQobuzPlaylistSync(urlHash) {
|
||
try {
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.is_qobuz_playlist) {
|
||
console.error('❌ Invalid Qobuz playlist state for sync');
|
||
return;
|
||
}
|
||
|
||
const playlistId = state.qobuz_playlist_id;
|
||
const response = await fetch(`/api/qobuz/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;
|
||
|
||
updateQobuzCardPhase(playlistId, 'syncing');
|
||
updateQobuzModalButtons(urlHash, 'syncing');
|
||
startQobuzSyncPolling(urlHash, syncPlaylistId);
|
||
showToast('Qobuz playlist sync started!', 'success');
|
||
} catch (error) {
|
||
showToast(`Error starting sync: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function startQobuzSyncPolling(urlHash, syncPlaylistId) {
|
||
if (activeYouTubePollers[urlHash]) {
|
||
clearInterval(activeYouTubePollers[urlHash]);
|
||
}
|
||
|
||
const state = youtubePlaylistStates[urlHash];
|
||
const playlistId = state.qobuz_playlist_id;
|
||
syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId);
|
||
|
||
if (socketConnected && syncPlaylistId) {
|
||
socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] });
|
||
_syncProgressCallbacks[syncPlaylistId] = (data) => {
|
||
const progress = data.progress || {};
|
||
updateQobuzCardSyncProgress(playlistId, progress);
|
||
updateQobuzModalSyncProgress(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 (qobuzPlaylistStates[playlistId]) qobuzPlaylistStates[playlistId].phase = 'sync_complete';
|
||
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
|
||
updateQobuzCardPhase(playlistId, 'sync_complete');
|
||
updateQobuzModalButtons(urlHash, 'sync_complete');
|
||
showToast('Qobuz 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 (qobuzPlaylistStates[playlistId]) qobuzPlaylistStates[playlistId].phase = 'discovered';
|
||
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
|
||
updateQobuzCardPhase(playlistId, 'discovered');
|
||
updateQobuzModalButtons(urlHash, 'discovered');
|
||
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
|
||
}
|
||
};
|
||
}
|
||
|
||
const pollFunction = async () => {
|
||
if (socketConnected) return;
|
||
try {
|
||
const response = await fetch(`/api/qobuz/sync/status/${playlistId}`);
|
||
const status = await response.json();
|
||
if (status.error) {
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
return;
|
||
}
|
||
updateQobuzCardSyncProgress(playlistId, status.progress);
|
||
updateQobuzModalSyncProgress(urlHash, status.progress);
|
||
|
||
if (status.complete) {
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
if (qobuzPlaylistStates[playlistId]) qobuzPlaylistStates[playlistId].phase = 'sync_complete';
|
||
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
|
||
updateQobuzCardPhase(playlistId, 'sync_complete');
|
||
updateQobuzModalButtons(urlHash, 'sync_complete');
|
||
showToast('Qobuz playlist sync complete!', 'success');
|
||
} else if (status.sync_status === 'error') {
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
if (qobuzPlaylistStates[playlistId]) qobuzPlaylistStates[playlistId].phase = 'discovered';
|
||
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
|
||
updateQobuzCardPhase(playlistId, 'discovered');
|
||
updateQobuzModalButtons(urlHash, 'discovered');
|
||
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
if (activeYouTubePollers[urlHash]) {
|
||
clearInterval(activeYouTubePollers[urlHash]);
|
||
delete activeYouTubePollers[urlHash];
|
||
}
|
||
}
|
||
};
|
||
|
||
if (!socketConnected) pollFunction();
|
||
const pollInterval = setInterval(pollFunction, 1000);
|
||
activeYouTubePollers[urlHash] = pollInterval;
|
||
}
|
||
|
||
async function cancelQobuzSync(urlHash) {
|
||
try {
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.is_qobuz_playlist) return;
|
||
|
||
const playlistId = state.qobuz_playlist_id;
|
||
const response = await fetch(`/api/qobuz/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];
|
||
}
|
||
|
||
updateQobuzCardPhase(playlistId, 'discovered');
|
||
updateQobuzModalButtons(urlHash, 'discovered');
|
||
showToast('Qobuz sync cancelled', 'info');
|
||
} catch (error) {
|
||
showToast(`Error cancelling sync: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function updateQobuzCardSyncProgress(playlistId, progress) {
|
||
const state = qobuzPlaylistStates[playlistId];
|
||
if (!state || !state.playlist || !progress) return;
|
||
state.lastSyncProgress = progress;
|
||
|
||
const card = document.getElementById(`qobuz-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 = `
|
||
<div class="playlist-card-sync-status">
|
||
<span class="sync-stat total-tracks">♪ ${total}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ ${matched}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ ${failed}</span>
|
||
<span class="sync-stat percentage">(${percentage}%)</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (statusCounterHTML) {
|
||
progressElement.innerHTML = statusCounterHTML;
|
||
}
|
||
}
|
||
|
||
function updateQobuzModalSyncProgress(urlHash, progress) {
|
||
const statusDisplay = document.getElementById(`tidal-sync-status-${urlHash}`);
|
||
if (!statusDisplay || !progress) return;
|
||
|
||
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;
|
||
|
||
if (total > 0) {
|
||
const processed = matched + failed;
|
||
const percentage = Math.round((processed / total) * 100);
|
||
if (percentageEl) percentageEl.textContent = percentage;
|
||
}
|
||
}
|
||
|
||
function updateQobuzModalButtons(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 startQobuzDownloadMissing(urlHash) {
|
||
try {
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.is_qobuz_playlist) 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 = `qobuz_${state.qobuz_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) {
|
||
showToast(`Error starting downloads: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
|
||
// ===================================================================
|
||
// 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 = `<div class="playlist-placeholder">🔄 Loading playlists...</div>`;
|
||
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 = `<div class="playlist-placeholder">❌ Error: ${error.message}</div>`;
|
||
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 = `<div class="playlist-placeholder">No Deezer playlists found.</div>`;
|
||
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 `
|
||
<div class="playlist-card deezer-arl-playlist-card" data-playlist-id="${arlId}">
|
||
<div class="playlist-card-main">
|
||
<div class="playlist-card-content">
|
||
<div class="playlist-card-name">${escapeHtml(p.name)}</div>
|
||
<div class="playlist-card-info">
|
||
<span>${p.track_count} tracks</span> •
|
||
<span class="playlist-card-status ${statusClass}">${p.sync_status || 'Never Synced'}</span>
|
||
</div>
|
||
<div class="sync-progress-indicator" id="progress-${arlId}"></div>
|
||
</div>
|
||
<div class="playlist-card-actions">
|
||
<button id="action-btn-${arlId}" onclick="openDeezerArlPlaylistDetailsModal(event, '${p.id}')">Sync / Download</button>
|
||
<button id="progress-btn-${arlId}" class="view-progress-btn hidden" onclick="handleDeezerArlViewProgressClick(event, '${p.id}')">
|
||
View Progress
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).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 = `
|
||
<div class="modal-container playlist-modal">
|
||
<div class="playlist-modal-header">
|
||
<div class="playlist-header-content">
|
||
<h2>${escapeHtml(playlist.name)}</h2>
|
||
<div class="playlist-quick-info">
|
||
<span class="playlist-track-count">${playlist.track_count || (playlist.tracks ? playlist.tracks.length : 0)} tracks</span>
|
||
<span class="playlist-owner">by ${escapeHtml(playlist.owner || '')}</span>
|
||
</div>
|
||
<div class="playlist-modal-sync-status" id="modal-sync-status-${playlistId}" style="display: none;">
|
||
<span class="sync-stat total-tracks">♪ <span id="modal-total-${playlistId}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ <span id="modal-matched-${playlistId}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ <span id="modal-failed-${playlistId}">0</span></span>
|
||
<span class="sync-stat percentage">(<span id="modal-percentage-${playlistId}">0</span>%)</span>
|
||
</div>
|
||
</div>
|
||
<span class="playlist-modal-close" onclick="closeDeezerArlPlaylistDetailsModal()">×</span>
|
||
</div>
|
||
|
||
<div class="playlist-modal-body">
|
||
${playlist.description ? `<div class="playlist-description">${escapeHtml(playlist.description)}</div>` : ''}
|
||
|
||
<div class="playlist-tracks-container">
|
||
<div class="playlist-tracks-list">
|
||
${(playlist.tracks || []).map((track, index) => `
|
||
<div class="playlist-track-item">
|
||
<span class="playlist-track-number">${index + 1}</span>
|
||
<div class="playlist-track-info">
|
||
<div class="playlist-track-name">${escapeHtml(track.name)}</div>
|
||
<div class="playlist-track-artists">${formatArtists(track.artists)}</div>
|
||
</div>
|
||
<div class="playlist-track-duration">${formatDuration(track.duration_ms)}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="playlist-modal-footer">
|
||
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeDeezerArlPlaylistDetailsModal()">Close</button>
|
||
<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="closeDeezerArlPlaylistDetailsModal(); openDownloadMissingModal('${playlistId}')">
|
||
${hasCompletedProcess ? '📊 View Download Results' : '📥 Download Missing Tracks'}
|
||
</button>
|
||
<select id="sync-mode-${playlistId}" class="playlist-modal-sync-mode" title="Replace overwrites the server playlist; Append only adds new tracks (preserves user-added)" ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>
|
||
<option value="replace" selected>Replace</option>
|
||
<option value="append">Append only</option>
|
||
</select>
|
||
<button id="sync-btn-${playlistId}" class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSync('${playlistId}')" ${isSyncing ? 'disabled' : ''} ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>${isSyncing ? '⏳ Syncing...' : 'Sync Playlist'}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 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 = `<div class="playlist-placeholder">Paste a Deezer playlist URL above to get started.</div>`;
|
||
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 `
|
||
<div class="youtube-playlist-card deezer-playlist-card" id="deezer-card-${playlist.id}">
|
||
<div class="playlist-card-icon">🎵</div>
|
||
<div class="playlist-card-content">
|
||
<div class="playlist-card-name">${escapeHtml(playlist.name)}</div>
|
||
<div class="playlist-card-info">
|
||
<span class="playlist-card-track-count">${playlist.track_count || playlist.tracks.length} tracks</span>
|
||
<span class="playlist-card-phase-text" style="color: ${phaseColor};">${phaseText}</span>
|
||
</div>
|
||
</div>
|
||
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}">
|
||
<!-- Progress will be dynamically updated based on phase -->
|
||
</div>
|
||
<button class="playlist-card-action-btn">${buttonText}</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="playlist-card-sync-status">
|
||
<span class="sync-stat matched-tracks">✓ ${matches}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat total-tracks">♪ ${total}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// 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 = `
|
||
<div class="playlist-card-sync-status">
|
||
<span class="sync-stat total-tracks">♪ ${total}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ ${matched}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ ${failed}</span>
|
||
<span class="sync-stat percentage">(${percentage}%)</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 = `<div class="playlist-placeholder">Deezer ARL not configured. Add your ARL token in Settings > Downloads to see your playlists here.</div>`;
|
||
}
|
||
}).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();
|
||
}
|
||
|
||
// Auto-load ListenBrainz Sync-tab playlists on first activation.
|
||
// Reuses the LB discovery + sync flow already wired up for the
|
||
// Discover page — the tab is purely a Sync-page entry point.
|
||
// Tab id is ``listenbrainz-sync`` (not ``listenbrainz``) so the
|
||
// ``${tabId}-tab-content`` lookup doesn't collide with the
|
||
// Discover page's own ``id="listenbrainz-tab-content"``.
|
||
if (tabId === 'listenbrainz-sync') {
|
||
if (typeof loadListenBrainzSyncPlaylists === 'function'
|
||
&& !window._listenbrainzSyncTabLoaded) {
|
||
window._listenbrainzSyncTabLoaded = true;
|
||
loadListenBrainzSyncPlaylists();
|
||
}
|
||
// Cards mirror canonical listenbrainzPlaylistStates via a
|
||
// 500ms refresh loop that auto-stops when the tab loses
|
||
// active state — gives parity with Tidal/Qobuz live updates.
|
||
if (typeof _startLbSyncCardRefreshLoop === 'function') {
|
||
_startLbSyncCardRefreshLoop();
|
||
}
|
||
}
|
||
|
||
// Last.fm Sync tab — same MB-track shape as LB, shares the
|
||
// listenbrainzPlaylistStates machinery + refresh loop.
|
||
if (tabId === 'lastfm-sync') {
|
||
if (typeof loadLastfmSyncPlaylists === 'function'
|
||
&& !window._lastfmSyncTabLoaded) {
|
||
window._lastfmSyncTabLoaded = true;
|
||
loadLastfmSyncPlaylists();
|
||
}
|
||
if (typeof _startLbSyncCardRefreshLoop === 'function') {
|
||
_startLbSyncCardRefreshLoop();
|
||
}
|
||
}
|
||
|
||
// SoulSync Discovery Sync tab — personalized_playlists pre-
|
||
// matched, no discovery hop needed; click → refresh kind →
|
||
// mirror under synthetic id.
|
||
if (tabId === 'soulsync-discovery-sync') {
|
||
if (typeof loadSoulsyncDiscoverySyncPlaylists === 'function'
|
||
&& !window._soulsyncDiscoverySyncTabLoaded) {
|
||
window._soulsyncDiscoverySyncTabLoaded = true;
|
||
loadSoulsyncDiscoverySyncPlaylists();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// 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 Qobuz refresh button
|
||
const qobuzRefreshBtn = document.getElementById('qobuz-refresh-btn');
|
||
if (qobuzRefreshBtn) {
|
||
qobuzRefreshBtn.removeEventListener('click', loadQobuzPlaylists);
|
||
qobuzRefreshBtn.addEventListener('click', loadQobuzPlaylists);
|
||
}
|
||
|
||
// 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 iTunes Link parse button
|
||
const itunesLinkParseBtn = document.getElementById('itunes-link-parse-btn');
|
||
if (itunesLinkParseBtn) {
|
||
itunesLinkParseBtn.addEventListener('click', parseITunesLinkUrl);
|
||
}
|
||
|
||
// Logic for iTunes Link URL input (Enter key support)
|
||
const itunesLinkUrlInput = document.getElementById('itunes-link-url-input');
|
||
if (itunesLinkUrlInput) {
|
||
itunesLinkUrlInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') {
|
||
parseITunesLinkUrl();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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 = `
|
||
<div class="playlist-placeholder">Your created Beatport playlists will appear here.</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="genre-loading-placeholder">
|
||
<div class="loading-spinner"></div>
|
||
<p>🔍 Discovering current Beatport genres...</p>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="genre-error-placeholder">
|
||
<p>⚠️ No genres available</p>
|
||
<button onclick="loadBeatportGenres()" class="refresh-genres-btn">🔄 Retry</button>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Generate genre cards dynamically (without images first)
|
||
const genreCardsHTML = genres.map(genre => `
|
||
<div class="beatport-genre-item"
|
||
data-genre-slug="${genre.slug}"
|
||
data-genre-id="${genre.id}"
|
||
data-genre-name="${genre.name}">
|
||
<div class="genre-icon">🎵</div>
|
||
<h3>${genre.name}</h3>
|
||
<span class="track-count">Top 100</span>
|
||
</div>
|
||
`).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 = `
|
||
<div class="genre-error-placeholder">
|
||
<p>❌ Failed to load genres: ${error.message}</p>
|
||
<button onclick="loadBeatportGenres()" class="refresh-genres-btn">🔄 Retry</button>
|
||
</div>
|
||
`;
|
||
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 `
|
||
<div class="youtube-playlist-card" id="beatport-card-${chartData.hash}">
|
||
<div class="playlist-card-icon">🎧</div>
|
||
<div class="playlist-card-content">
|
||
<div class="playlist-card-name">${escapeHtml(chartData.name)}</div>
|
||
<div class="playlist-card-info">
|
||
<span class="playlist-card-track-count">${chartData.track_count} tracks</span>
|
||
<span class="playlist-card-phase-text" style="color: ${phaseColor};">${phaseText}</span>
|
||
</div>
|
||
</div>
|
||
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}">
|
||
<!-- Progress will be dynamically updated based on phase -->
|
||
</div>
|
||
<button class="playlist-card-action-btn">${buttonText}</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="new-charts-empty">
|
||
<h4>No Charts Available</h4>
|
||
<p>No curated charts found for ${genreName} at the moment.</p>
|
||
</div>
|
||
`;
|
||
} 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 `
|
||
<div class="new-chart-item" data-chart-url="${chartUrl}" data-chart-name="${chartName}" data-chart-artist="${artistName}">
|
||
<div class="new-chart-header">
|
||
<div class="new-chart-icon">📈</div>
|
||
<div class="new-chart-title">
|
||
<h5>${chartName}</h5>
|
||
<p class="new-chart-artist">by ${artistName}</p>
|
||
</div>
|
||
</div>
|
||
<div class="new-chart-description">
|
||
Curated ${genreName} chart collection
|
||
</div>
|
||
<div class="new-chart-footer">
|
||
<div class="new-chart-type">Chart</div>
|
||
<div class="new-chart-action">Explore →</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).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 = `
|
||
<div class="new-charts-empty">
|
||
<h4>Error Loading Charts</h4>
|
||
<p>Unable to load chart collections for ${genreName}.</p>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="new-charts-empty">
|
||
<h4>No DJ Charts Available</h4>
|
||
<p>No DJ curated charts found at the moment.</p>
|
||
</div>
|
||
`;
|
||
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 `
|
||
<div class="new-chart-item" data-chart-url="${chartUrl}" data-chart-name="${chartName}" data-chart-artist="${artistName}">
|
||
<div class="new-chart-header">
|
||
<div class="new-chart-icon">🎧</div>
|
||
<div class="new-chart-title">
|
||
<h5>${chartName}</h5>
|
||
<p class="new-chart-artist">by ${artistName}</p>
|
||
</div>
|
||
</div>
|
||
<div class="new-chart-description">
|
||
DJ curated chart collection
|
||
</div>
|
||
<div class="new-chart-footer">
|
||
<div class="new-chart-type">DJ Chart</div>
|
||
<div class="new-chart-action">Explore →</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).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 = `
|
||
<div class="new-charts-empty">
|
||
<h4>Error Loading DJ Charts</h4>
|
||
<p>Unable to load DJ chart collections.</p>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="new-charts-empty">
|
||
<h4>No Featured Charts Available</h4>
|
||
<p>No featured curated charts found at the moment.</p>
|
||
</div>
|
||
`;
|
||
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 `
|
||
<div class="new-chart-item" data-chart-url="${chartUrl}" data-chart-name="${chartName}" data-chart-artist="${artistName}">
|
||
<div class="new-chart-header">
|
||
<div class="new-chart-icon">⭐</div>
|
||
<div class="new-chart-title">
|
||
<h5>${chartName}</h5>
|
||
<p class="new-chart-artist">by ${artistName}</p>
|
||
</div>
|
||
</div>
|
||
<div class="new-chart-description">
|
||
Editor curated chart collection
|
||
</div>
|
||
<div class="new-chart-footer">
|
||
<div class="new-chart-type">Featured Chart</div>
|
||
<div class="new-chart-action">Explore →</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).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 = `
|
||
<div class="new-charts-empty">
|
||
<h4>Error Loading Featured Charts</h4>
|
||
<p>Unable to load featured chart collections.</p>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="genre-charts-empty">
|
||
<h3>No Charts Available</h3>
|
||
<p>No curated charts found for ${genreName} at the moment.<br>Check back later for new DJ and artist chart collections.</p>
|
||
</div>
|
||
`;
|
||
} 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 `
|
||
<div class="genre-chart-item" data-chart-url="${chartUrl}" data-chart-name="${chartName}" data-chart-artist="${artistName}">
|
||
<div class="chart-item-header">
|
||
<div class="chart-item-icon">📈</div>
|
||
<div class="chart-item-title">
|
||
<h4>${chartName}</h4>
|
||
<p class="chart-item-artist">by ${artistName}</p>
|
||
</div>
|
||
</div>
|
||
<div class="chart-item-description">
|
||
Curated chart collection featuring ${genreName} tracks
|
||
</div>
|
||
<div class="chart-item-footer">
|
||
<div class="chart-item-type">Chart</div>
|
||
<div class="chart-item-action">Click to explore →</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).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 = `
|
||
<div class="genre-charts-empty">
|
||
<h3>Error Loading Charts</h3>
|
||
<p>Unable to load chart collections for ${genreName}.<br>Please try again later.</p>
|
||
</div>
|
||
`;
|
||
|
||
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 = `<div class="playlist-placeholder">Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.</div>`;
|
||
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 `
|
||
<div class="youtube-playlist-card spotify-public-card" id="spotify-public-card-${playlist.url_hash}">
|
||
<div class="playlist-card-icon">${isAlbum ? '💿' : '🎵'}</div>
|
||
<div class="playlist-card-content">
|
||
<div class="playlist-card-name">${escapeHtml(playlist.name)}</div>
|
||
<div class="playlist-card-info">
|
||
<span class="playlist-card-type-badge" style="color: ${isAlbum ? '#b3b3b3' : '#1DB954'}; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.5px;">${isAlbum ? 'Album' : 'Playlist'}</span>
|
||
<span class="playlist-card-track-count">${playlist.track_count || playlist.tracks.length} tracks</span>
|
||
<span class="playlist-card-phase-text" style="color: ${phaseColor};">${phaseText}</span>
|
||
</div>
|
||
</div>
|
||
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}">
|
||
<!-- Progress will be dynamically updated based on phase -->
|
||
</div>
|
||
<button class="playlist-card-action-btn">${buttonText}</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="playlist-card-sync-status">
|
||
<span class="sync-stat matched-tracks">✓ ${matches}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat total-tracks">♪ ${total}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// 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 = `
|
||
<div class="playlist-card-sync-status">
|
||
<span class="sync-stat total-tracks">♪ ${total}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ ${matched}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ ${failed}</span>
|
||
<span class="sync-stat percentage">(${percentage}%)</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
|
||
// ===============================
|
||
// ITUNES LINK FUNCTIONALITY
|
||
// ===============================
|
||
|
||
let itunesLinkPlaylists = []; // Array of loaded iTunes Link playlist objects
|
||
let itunesLinkPlaylistStates = {}; // Key: url_hash, Value: state dict
|
||
|
||
async function parseITunesLinkUrl() {
|
||
const urlInput = document.getElementById('itunes-link-url-input');
|
||
const url = urlInput.value.trim();
|
||
|
||
if (!url) {
|
||
showToast('Please enter a iTunes URL', 'error');
|
||
return;
|
||
}
|
||
|
||
// Basic URL validation
|
||
const lowerUrl = url.toLowerCase();
|
||
if (!lowerUrl.includes('itunes.apple.com') && !lowerUrl.includes('music.apple.com') &&
|
||
!lowerUrl.startsWith('itunes:album:') && !lowerUrl.startsWith('itunes:track:') &&
|
||
!lowerUrl.startsWith('itunes:playlist:') && !lowerUrl.startsWith('applemusic:album:') &&
|
||
!lowerUrl.startsWith('applemusic:track:') && !lowerUrl.startsWith('applemusic:playlist:')) {
|
||
showToast('Please enter a valid iTunes or Apple Music URL', 'error');
|
||
return;
|
||
}
|
||
|
||
// Check if already loaded
|
||
if (_isUrlAlreadyLoaded('itunes-link', url)) {
|
||
showToast('This playlist is already loaded', 'info');
|
||
urlInput.value = '';
|
||
return;
|
||
}
|
||
|
||
const parseBtn = document.getElementById('itunes-link-parse-btn');
|
||
if (parseBtn) {
|
||
parseBtn.disabled = true;
|
||
parseBtn.textContent = 'Loading...';
|
||
}
|
||
|
||
try {
|
||
console.log('🎵 Parsing public iTunes URL:', url);
|
||
|
||
const response = await fetch('/api/itunes-link/parse', {
|
||
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 (itunesLinkPlaylists.find(p => String(p.url_hash) === String(result.url_hash))) {
|
||
showToast('This playlist is already loaded', 'info');
|
||
urlInput.value = '';
|
||
return;
|
||
}
|
||
|
||
console.log(`✅ iTunes ${result.type} parsed: ${result.name} (${result.track_count} tracks)`);
|
||
|
||
itunesLinkPlaylists.push(result);
|
||
|
||
// Auto-mirror
|
||
if (result.tracks && result.tracks.length > 0) {
|
||
mirrorPlaylist('itunes_link', result.url_hash, result.name, result.tracks.map(t => ({
|
||
track_name: t.name || '',
|
||
artist_name: Array.isArray(t.artists) ? t.artists.map(a => (typeof a === 'object' && a !== null) ? a.name : a).filter(Boolean).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('itunes-link', url, result.name);
|
||
|
||
renderITunesLinkPlaylists();
|
||
await loadITunesLinkPlaylistStatesFromBackend();
|
||
|
||
urlInput.value = '';
|
||
showToast(`Loaded: ${result.name} (${result.track_count} tracks)`, 'success');
|
||
console.log(`🎵 Loaded iTunes link: ${result.name}`);
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error parsing iTunes URL:', error);
|
||
showToast(`Error parsing iTunes URL: ${error.message}`, 'error');
|
||
} finally {
|
||
if (parseBtn) {
|
||
parseBtn.disabled = false;
|
||
parseBtn.textContent = 'Load';
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderITunesLinkPlaylists() {
|
||
const container = document.getElementById('itunes-link-playlist-container');
|
||
if (itunesLinkPlaylists.length === 0) {
|
||
container.innerHTML = `<div class="playlist-placeholder">Paste an iTunes or Apple Music album/track URL above to load tracks.</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = itunesLinkPlaylists.map(p => {
|
||
if (!itunesLinkPlaylistStates[p.url_hash]) {
|
||
itunesLinkPlaylistStates[p.url_hash] = {
|
||
phase: 'fresh',
|
||
playlist: p
|
||
};
|
||
}
|
||
return createITunesLinkCard(p);
|
||
}).join('');
|
||
|
||
// Add click handlers to cards
|
||
itunesLinkPlaylists.forEach(p => {
|
||
const card = document.getElementById(`itunes-link-card-${p.url_hash}`);
|
||
if (card) {
|
||
card.addEventListener('click', () => handleITunesLinkCardClick(p.url_hash));
|
||
}
|
||
});
|
||
}
|
||
|
||
function createITunesLinkCard(playlist) {
|
||
const state = itunesLinkPlaylistStates[playlist.url_hash];
|
||
const phase = state ? state.phase : 'fresh';
|
||
const isAlbum = playlist.type === 'album';
|
||
const isPlaylist = playlist.type === 'playlist';
|
||
|
||
let buttonText = getActionButtonText(phase);
|
||
let phaseText = getPhaseText(phase);
|
||
let phaseColor = getPhaseColor(phase);
|
||
|
||
return `
|
||
<div class="youtube-playlist-card itunes-link-card" id="itunes-link-card-${playlist.url_hash}">
|
||
<div class="playlist-card-icon">${isAlbum ? '💿' : (isPlaylist ? '♫' : '🎵')}</div>
|
||
<div class="playlist-card-content">
|
||
<div class="playlist-card-name">${escapeHtml(playlist.name)}</div>
|
||
<div class="playlist-card-info">
|
||
<span class="playlist-card-type-badge" style="color: ${isAlbum ? '#b3b3b3' : '#fa586a'}; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.5px;">${isAlbum ? 'Album' : (isPlaylist ? 'Playlist' : 'Track')}</span>
|
||
<span class="playlist-card-track-count">${playlist.track_count || playlist.tracks.length} tracks</span>
|
||
<span class="playlist-card-phase-text" style="color: ${phaseColor};">${phaseText}</span>
|
||
</div>
|
||
</div>
|
||
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}">
|
||
<!-- Progress will be dynamically updated based on phase -->
|
||
</div>
|
||
<button class="playlist-card-action-btn">${buttonText}</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function handleITunesLinkCardClick(urlHash) {
|
||
const state = itunesLinkPlaylistStates[urlHash];
|
||
if (!state) {
|
||
console.error(`No state found for iTunes Link playlist: ${urlHash}`);
|
||
showToast('Playlist state not found - try refreshing the page', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!state.playlist) {
|
||
console.error(`No playlist data found for iTunes Link playlist: ${urlHash}`);
|
||
showToast('Playlist data missing - try refreshing the page', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!state.phase) {
|
||
state.phase = 'fresh';
|
||
}
|
||
|
||
console.log(`🎵 [Card Click] iTunes Link card clicked: ${urlHash}, Phase: ${state.phase}`);
|
||
|
||
if (state.phase === 'fresh') {
|
||
console.log(`🎵 Using pre-loaded iTunes Link playlist data for: ${state.playlist.name}`);
|
||
openITunesLinkDiscoveryModal(urlHash, state.playlist);
|
||
|
||
} else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') {
|
||
console.log(`🎵 [Card Click] Opening iTunes Link discovery modal for ${state.phase} phase`);
|
||
|
||
if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) {
|
||
try {
|
||
const stateResponse = await fetch(`/api/itunes-link/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;
|
||
itunesLinkPlaylistStates[urlHash] = { ...itunesLinkPlaylistStates[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}`);
|
||
}
|
||
}
|
||
|
||
openITunesLinkDiscoveryModal(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 rehydrateITunesLinkDownloadModal(urlHash, state);
|
||
}
|
||
} else {
|
||
await rehydrateITunesLinkDownloadModal(urlHash, state);
|
||
}
|
||
} else {
|
||
if (state.discovery_results && state.discovery_results.length > 0) {
|
||
openITunesLinkDiscoveryModal(urlHash, state.playlist);
|
||
} else {
|
||
showToast('Unable to open download modal - missing playlist data', 'error');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function rehydrateITunesLinkDownloadModal(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 || `itunes_link_${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 iTunes Link download modal: ${error}`);
|
||
}
|
||
}
|
||
|
||
async function openITunesLinkDiscoveryModal(urlHash, playlistData) {
|
||
console.log(`🎵 Opening iTunes Link discovery modal (reusing YouTube modal): ${playlistData.name}`);
|
||
|
||
const fakeUrlHash = `ituneslink_${urlHash}`;
|
||
|
||
const cardState = itunesLinkPlaylistStates[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.itunes_link_track ? result.itunes_link_track.name : 'Unknown',
|
||
yt_artist: result.itunes_link_track ? (result.itunes_link_track.artists ? result.itunes_link_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(`🎵 iTunes Link 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_itunes_link_playlist: true,
|
||
itunes_link_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 iTunes Link discovery for: ${playlistData.name}`);
|
||
|
||
const response = await fetch(`/api/itunes-link/discovery/start/${urlHash}`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.error) {
|
||
console.error('Error starting iTunes Link discovery:', result.error);
|
||
showToast(`Error starting discovery: ${result.error}`, 'error');
|
||
return;
|
||
}
|
||
|
||
console.log('iTunes Link discovery started, beginning polling...');
|
||
|
||
itunesLinkPlaylistStates[urlHash].phase = 'discovering';
|
||
updateITunesLinkCardPhase(urlHash, 'discovering');
|
||
youtubePlaylistStates[fakeUrlHash].phase = 'discovering';
|
||
|
||
startITunesLinkDiscoveryPolling(fakeUrlHash, urlHash);
|
||
|
||
} catch (error) {
|
||
console.error('Error starting iTunes Link discovery:', error);
|
||
showToast(`Error starting discovery: ${error.message}`, 'error');
|
||
}
|
||
} else if (isCurrentlyDiscovering) {
|
||
console.log(`🔄 Resuming iTunes Link discovery polling for: ${playlistData.name}`);
|
||
startITunesLinkDiscoveryPolling(fakeUrlHash, urlHash);
|
||
} else if (cardState && cardState.phase === 'syncing') {
|
||
console.log(`🔄 Resuming iTunes Link sync polling for: ${playlistData.name}`);
|
||
startITunesLinkSyncPolling(fakeUrlHash);
|
||
} else {
|
||
console.log('Using existing results - no need to re-discover');
|
||
}
|
||
|
||
openYouTubeDiscoveryModal(fakeUrlHash);
|
||
}
|
||
|
||
function startITunesLinkDiscoveryPolling(fakeUrlHash, urlHash) {
|
||
console.log(`🔄 Starting iTunes Link 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.itunes_link_track ? r.itunes_link_track.name : 'Unknown',
|
||
yt_artist: r.itunes_link_track ? (r.itunes_link_track.artists ? r.itunes_link_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 (itunesLinkPlaylistStates[urlHash]) {
|
||
itunesLinkPlaylistStates[urlHash].phase = data.phase;
|
||
itunesLinkPlaylistStates[urlHash].discovery_results = data.results;
|
||
itunesLinkPlaylistStates[urlHash].spotify_matches = data.spotify_matches;
|
||
itunesLinkPlaylistStates[urlHash].discovery_progress = data.progress;
|
||
updateITunesLinkCardPhase(urlHash, data.phase);
|
||
}
|
||
updateITunesLinkCardProgress(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/itunes-link/discovery/status/${urlHash}`);
|
||
const status = await response.json();
|
||
|
||
if (status.error) {
|
||
console.error('Error polling iTunes Link 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.itunes_link_track ? result.itunes_link_track.name : 'Unknown',
|
||
yt_artist: result.itunes_link_track ? (result.itunes_link_track.artists ? result.itunes_link_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 (itunesLinkPlaylistStates[urlHash]) {
|
||
itunesLinkPlaylistStates[urlHash].phase = status.phase;
|
||
itunesLinkPlaylistStates[urlHash].discovery_results = status.results;
|
||
itunesLinkPlaylistStates[urlHash].spotify_matches = status.spotify_matches;
|
||
itunesLinkPlaylistStates[urlHash].discovery_progress = status.progress;
|
||
updateITunesLinkCardPhase(urlHash, status.phase);
|
||
}
|
||
|
||
updateITunesLinkCardProgress(urlHash, status);
|
||
|
||
console.log(`🔄 iTunes Link discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`);
|
||
}
|
||
|
||
if (status.complete) {
|
||
console.log(`iTunes Link discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`);
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[fakeUrlHash];
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error polling iTunes Link discovery:', error);
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[fakeUrlHash];
|
||
}
|
||
}, 1000);
|
||
|
||
activeYouTubePollers[fakeUrlHash] = pollInterval;
|
||
}
|
||
|
||
async function loadITunesLinkPlaylistStatesFromBackend() {
|
||
try {
|
||
console.log('🎵 Loading iTunes Link playlist states from backend...');
|
||
|
||
const response = await fetch('/api/itunes-link/playlists/states');
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || 'Failed to fetch iTunes Link playlist states');
|
||
}
|
||
|
||
const data = await response.json();
|
||
const states = data.states || [];
|
||
|
||
console.log(`🎵 Found ${states.length} stored iTunes Link playlist states in backend`);
|
||
|
||
if (states.length === 0) return;
|
||
|
||
for (const stateInfo of states) {
|
||
await applyITunesLinkPlaylistState(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 iTunes Link playlist: ${stateInfo.playlist_id}`);
|
||
try {
|
||
const playlistData = itunesLinkPlaylists.find(p => String(p.url_hash) === String(stateInfo.playlist_id));
|
||
if (!playlistData) continue;
|
||
|
||
const spotifyTracks = itunesLinkPlaylistStates[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 iTunes Link download modal for ${stateInfo.playlist_id}:`, error);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log('iTunes Link playlist states loaded and applied');
|
||
|
||
} catch (error) {
|
||
console.error('Error loading iTunes Link playlist states:', error);
|
||
}
|
||
}
|
||
|
||
async function applyITunesLinkPlaylistState(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 iTunes Link playlist: ${playlist_id}, Phase: ${phase}`);
|
||
|
||
const playlistData = itunesLinkPlaylists.find(p => String(p.url_hash) === String(playlist_id));
|
||
if (!playlistData) {
|
||
console.warn(`Playlist data not found for state ${playlist_id} - skipping`);
|
||
return;
|
||
}
|
||
|
||
if (!itunesLinkPlaylistStates[playlist_id]) {
|
||
itunesLinkPlaylistStates[playlist_id] = {
|
||
playlist: playlistData,
|
||
phase: 'fresh'
|
||
};
|
||
}
|
||
|
||
itunesLinkPlaylistStates[playlist_id].phase = phase;
|
||
itunesLinkPlaylistStates[playlist_id].discovery_progress = discovery_progress;
|
||
itunesLinkPlaylistStates[playlist_id].spotify_matches = spotify_matches;
|
||
itunesLinkPlaylistStates[playlist_id].discovery_results = discovery_results;
|
||
itunesLinkPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id;
|
||
itunesLinkPlaylistStates[playlist_id].download_process_id = download_process_id;
|
||
itunesLinkPlaylistStates[playlist_id].playlist = playlistData;
|
||
|
||
if (phase !== 'fresh' && phase !== 'discovering') {
|
||
try {
|
||
const stateResponse = await fetch(`/api/itunes-link/state/${playlist_id}`);
|
||
if (stateResponse.ok) {
|
||
const fullState = await stateResponse.json();
|
||
if (fullState.discovery_results && itunesLinkPlaylistStates[playlist_id]) {
|
||
itunesLinkPlaylistStates[playlist_id].discovery_results = fullState.discovery_results;
|
||
itunesLinkPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress;
|
||
itunesLinkPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches;
|
||
itunesLinkPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
|
||
itunesLinkPlaylistStates[playlist_id].download_process_id = fullState.download_process_id;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn(`Error fetching full discovery results for iTunes Link playlist ${playlistData.name}:`, error.message);
|
||
}
|
||
}
|
||
|
||
updateITunesLinkCardPhase(playlist_id, phase);
|
||
|
||
if (phase === 'discovered' && itunesLinkPlaylistStates[playlist_id]) {
|
||
const progressInfo = {
|
||
spotify_total: playlistData.track_count || playlistData.tracks?.length || 0,
|
||
spotify_matches: itunesLinkPlaylistStates[playlist_id].spotify_matches || 0
|
||
};
|
||
updateITunesLinkCardProgress(playlist_id, progressInfo);
|
||
}
|
||
|
||
if (phase === 'discovering') {
|
||
const fakeUrlHash = `ituneslink_${playlist_id}`;
|
||
startITunesLinkDiscoveryPolling(fakeUrlHash, playlist_id);
|
||
} else if (phase === 'syncing') {
|
||
const fakeUrlHash = `ituneslink_${playlist_id}`;
|
||
startITunesLinkSyncPolling(fakeUrlHash);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`Error applying iTunes Link playlist state for ${playlist_id}:`, error);
|
||
}
|
||
}
|
||
|
||
function updateITunesLinkCardPhase(urlHash, phase) {
|
||
const state = itunesLinkPlaylistStates[urlHash];
|
||
if (!state) return;
|
||
|
||
state.phase = phase;
|
||
|
||
const card = document.getElementById(`itunes-link-card-${urlHash}`);
|
||
if (card) {
|
||
const newCardHtml = createITunesLinkCard(state.playlist);
|
||
card.outerHTML = newCardHtml;
|
||
|
||
const newCard = document.getElementById(`itunes-link-card-${urlHash}`);
|
||
if (newCard) {
|
||
newCard.addEventListener('click', () => handleITunesLinkCardClick(urlHash));
|
||
}
|
||
|
||
if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) {
|
||
setTimeout(() => {
|
||
updateITunesLinkCardSyncProgress(urlHash, state.lastSyncProgress);
|
||
}, 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateITunesLinkCardProgress(urlHash, progress) {
|
||
const state = itunesLinkPlaylistStates[urlHash];
|
||
if (!state) return;
|
||
|
||
const card = document.getElementById(`itunes-link-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 = `
|
||
<div class="playlist-card-sync-status">
|
||
<span class="sync-stat matched-tracks">✓ ${matches}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat total-tracks">♪ ${total}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// SPOTIFY PUBLIC SYNC FUNCTIONALITY
|
||
// ===============================
|
||
|
||
async function startITunesLinkPlaylistSync(urlHash) {
|
||
try {
|
||
console.log('🎵 Starting iTunes Link playlist sync:', urlHash);
|
||
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.is_itunes_link_playlist) {
|
||
console.error('Invalid iTunes Link playlist state for sync');
|
||
return;
|
||
}
|
||
|
||
const playlistId = state.itunes_link_playlist_id;
|
||
const response = await fetch(`/api/itunes-link/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;
|
||
|
||
updateITunesLinkCardPhase(playlistId, 'syncing');
|
||
updateITunesLinkModalButtons(urlHash, 'syncing');
|
||
|
||
startITunesLinkSyncPolling(urlHash, syncPlaylistId);
|
||
|
||
showToast('iTunes Link playlist sync started!', 'success');
|
||
|
||
} catch (error) {
|
||
console.error('Error starting iTunes Link sync:', error);
|
||
showToast(`Error starting sync: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function startITunesLinkSyncPolling(urlHash, syncPlaylistId) {
|
||
if (activeYouTubePollers[urlHash]) {
|
||
clearInterval(activeYouTubePollers[urlHash]);
|
||
}
|
||
|
||
const state = youtubePlaylistStates[urlHash];
|
||
const playlistId = state.itunes_link_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 || {};
|
||
updateITunesLinkCardSyncProgress(playlistId, progress);
|
||
updateITunesLinkModalSyncProgress(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 (itunesLinkPlaylistStates[playlistId]) itunesLinkPlaylistStates[playlistId].phase = 'sync_complete';
|
||
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
|
||
updateITunesLinkCardPhase(playlistId, 'sync_complete');
|
||
updateITunesLinkModalButtons(urlHash, 'sync_complete');
|
||
showToast('iTunes Link 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 (itunesLinkPlaylistStates[playlistId]) itunesLinkPlaylistStates[playlistId].phase = 'discovered';
|
||
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
|
||
updateITunesLinkCardPhase(playlistId, 'discovered');
|
||
updateITunesLinkModalButtons(urlHash, 'discovered');
|
||
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
|
||
}
|
||
};
|
||
}
|
||
|
||
const pollFunction = async () => {
|
||
if (socketConnected) return;
|
||
try {
|
||
const response = await fetch(`/api/itunes-link/sync/status/${playlistId}`);
|
||
const status = await response.json();
|
||
|
||
if (status.error) {
|
||
console.error('Error polling iTunes Link sync status:', status.error);
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
return;
|
||
}
|
||
|
||
updateITunesLinkCardSyncProgress(playlistId, status.progress);
|
||
updateITunesLinkModalSyncProgress(urlHash, status.progress);
|
||
|
||
if (status.complete) {
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
if (itunesLinkPlaylistStates[playlistId]) itunesLinkPlaylistStates[playlistId].phase = 'sync_complete';
|
||
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete';
|
||
updateITunesLinkCardPhase(playlistId, 'sync_complete');
|
||
updateITunesLinkModalButtons(urlHash, 'sync_complete');
|
||
showToast('iTunes Link playlist sync complete!', 'success');
|
||
} else if (status.sync_status === 'error') {
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
if (itunesLinkPlaylistStates[playlistId]) itunesLinkPlaylistStates[playlistId].phase = 'discovered';
|
||
if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered';
|
||
updateITunesLinkCardPhase(playlistId, 'discovered');
|
||
updateITunesLinkModalButtons(urlHash, 'discovered');
|
||
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error polling iTunes Link sync:', error);
|
||
if (activeYouTubePollers[urlHash]) {
|
||
clearInterval(activeYouTubePollers[urlHash]);
|
||
delete activeYouTubePollers[urlHash];
|
||
}
|
||
}
|
||
};
|
||
|
||
if (!socketConnected) pollFunction();
|
||
|
||
const pollInterval = setInterval(pollFunction, 1000);
|
||
activeYouTubePollers[urlHash] = pollInterval;
|
||
}
|
||
|
||
async function cancelITunesLinkSync(urlHash) {
|
||
try {
|
||
console.log('Cancelling iTunes Link sync:', urlHash);
|
||
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.is_itunes_link_playlist) {
|
||
console.error('Invalid iTunes Link playlist state');
|
||
return;
|
||
}
|
||
|
||
const playlistId = state.itunes_link_playlist_id;
|
||
const response = await fetch(`/api/itunes-link/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];
|
||
}
|
||
|
||
updateITunesLinkCardPhase(playlistId, 'discovered');
|
||
updateITunesLinkModalButtons(urlHash, 'discovered');
|
||
|
||
showToast('iTunes Link sync cancelled', 'info');
|
||
|
||
} catch (error) {
|
||
console.error('Error cancelling iTunes Link sync:', error);
|
||
showToast(`Error cancelling sync: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function updateITunesLinkCardSyncProgress(urlHash, progress) {
|
||
const state = itunesLinkPlaylistStates[urlHash];
|
||
if (!state || !state.playlist || !progress) return;
|
||
|
||
state.lastSyncProgress = progress;
|
||
|
||
const card = document.getElementById(`itunes-link-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 = `
|
||
<div class="playlist-card-sync-status">
|
||
<span class="sync-stat total-tracks">♪ ${total}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ ${matched}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ ${failed}</span>
|
||
<span class="sync-stat percentage">(${percentage}%)</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (statusCounterHTML) {
|
||
progressElement.innerHTML = statusCounterHTML;
|
||
}
|
||
}
|
||
|
||
function updateITunesLinkModalSyncProgress(urlHash, progress) {
|
||
const statusDisplay = document.getElementById(`itunes-link-sync-status-${urlHash}`);
|
||
if (!statusDisplay || !progress) return;
|
||
|
||
const totalEl = document.getElementById(`itunes-link-total-${urlHash}`);
|
||
const matchedEl = document.getElementById(`itunes-link-matched-${urlHash}`);
|
||
const failedEl = document.getElementById(`itunes-link-failed-${urlHash}`);
|
||
const percentageEl = document.getElementById(`itunes-link-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 updateITunesLinkModalButtons(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 startITunesLinkDownloadMissing(urlHash) {
|
||
try {
|
||
console.log('🔍 Starting download missing tracks for iTunes Link playlist:', urlHash);
|
||
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.is_itunes_link_playlist) {
|
||
console.error('Invalid iTunes Link 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.itunes_link_playlist_id;
|
||
const virtualPlaylistId = `itunes_link_${realUrlHash}`;
|
||
const playlistName = state.playlist.name;
|
||
|
||
state.convertedSpotifyPlaylistId = virtualPlaylistId;
|
||
|
||
// Sync convertedSpotifyPlaylistId to itunesLinkPlaylistStates for card click routing
|
||
if (realUrlHash && itunesLinkPlaylistStates[realUrlHash]) {
|
||
itunesLinkPlaylistStates[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 iTunes Link 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() },
|
||
'itunes-link': { key: 'soulsync-url-history-itunes-link', icon: '♪', inputId: 'itunes-link-url-input', containerId: 'itunes-link-url-history', loadFn: () => parseITunesLinkUrl() }
|
||
};
|
||
|
||
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 = `<span class="url-history-bar-label">Recent</span>` +
|
||
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 `<div class="url-history-pill" data-url="${safeUrl}" title="${safeTitle}">
|
||
<span class="url-history-pill-icon">${cfg.icon}</span>
|
||
<span class="url-history-pill-name">${safeName}</span>
|
||
<button class="url-history-pill-remove" data-source="${source}" data-url="${safeUrl}">×</button>
|
||
</div>`;
|
||
}).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);
|
||
} else if (source === 'itunes-link') {
|
||
const parsed = extractITunesLinkId(url);
|
||
if (parsed && itunesLinkPlaylists.some(p => p.id === parsed.id && p.type === parsed.type)) return true;
|
||
return itunesLinkPlaylists.some(p => p.url === url);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function extractITunesLinkId(url) {
|
||
try {
|
||
const raw = (url || '').trim();
|
||
const uriMatch = raw.match(/^(?:itunes|applemusic):(album|track|playlist):([A-Za-z0-9._-]+)$/i);
|
||
if (uriMatch) return { type: uriMatch[1].toLowerCase(), id: uriMatch[2] };
|
||
const parsed = new URL(raw);
|
||
const trackId = parsed.searchParams.get('i');
|
||
if (trackId && /^\d+$/.test(trackId)) return { type: 'track', id: trackId };
|
||
const songMatch = parsed.pathname.match(/\/song(?:\/[^/]+)?\/(\d+)/);
|
||
if (songMatch) return { type: 'track', id: songMatch[1] };
|
||
const albumMatch = parsed.pathname.match(/\/album(?:\/[^/]+)?\/(\d+)/);
|
||
if (albumMatch) return { type: 'album', id: albumMatch[1] };
|
||
const playlistMatch = parsed.pathname.match(/\/playlist(?:\/[^/]+)?\/(pl\.[A-Za-z0-9._-]+)/);
|
||
if (playlistMatch) return { type: 'playlist', id: playlistMatch[1] };
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
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 = `
|
||
<div class="youtube-playlist-card" id="youtube-card-${tempHash}" data-url="${url}">
|
||
<div class="playlist-card-icon youtube-icon">▶</div>
|
||
<div class="playlist-card-content">
|
||
<div class="playlist-card-name">Parsing YouTube playlist...</div>
|
||
<div class="playlist-card-info">
|
||
<span class="playlist-card-track-count">-- tracks</span>
|
||
<span class="playlist-card-phase-text" style="color: #999;">Loading...</span>
|
||
</div>
|
||
</div>
|
||
<div class="playlist-card-progress hidden">
|
||
♪ 0 / ✓ 0 / ✗ 0 / 0%
|
||
</div>
|
||
<button class="playlist-card-action-btn" disabled>Parsing...</button>
|
||
</div>
|
||
`;
|
||
|
||
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_qobuz_playlist) {
|
||
startQobuzSyncPolling(urlHash);
|
||
} else if (state.is_deezer_playlist) {
|
||
startDeezerSyncPolling(urlHash);
|
||
} else if (state.is_spotify_public_playlist) {
|
||
startSpotifyPublicSyncPolling(urlHash);
|
||
} else if (state.is_itunes_link_playlist) {
|
||
startITunesLinkSyncPolling(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, Qobuz, Deezer, Beatport, ListenBrainz, Spotify Public, and Mirrored)
|
||
const isTidal = state.is_tidal_playlist;
|
||
const isQobuz = state.is_qobuz_playlist;
|
||
const isDeezer = state.is_deezer_playlist;
|
||
const isSpotifyPublic = state.is_spotify_public_playlist;
|
||
const isITunesLink = state.is_itunes_link_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' :
|
||
isITunesLink ? '🎵 iTunes Link Discovery' :
|
||
isDeezer ? '🎵 Deezer Playlist Discovery' :
|
||
isTidal ? '🎵 Tidal Playlist Discovery' :
|
||
isQobuz ? '🎵 Qobuz 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' :
|
||
isITunesLink ? 'iTunes' :
|
||
isDeezer ? 'Deezer' :
|
||
isTidal ? 'Tidal' :
|
||
isQobuz ? 'Qobuz' :
|
||
isBeatport ? 'Beatport' :
|
||
isLastfmRadio ? 'Last.fm' :
|
||
isListenBrainz ? 'LB' :
|
||
'YT';
|
||
|
||
const modalHtml = `
|
||
<div class="modal-overlay" id="youtube-discovery-modal-${urlHash}">
|
||
<div class="youtube-discovery-modal">
|
||
<div class="modal-header">
|
||
<h2>${modalTitle}</h2>
|
||
<div class="modal-subtitle">${state.playlist.name} (${state.playlist.tracks.length} tracks)</div>
|
||
<div class="modal-description">${getModalDescription(state.phase, isTidal, isBeatport, isListenBrainz, isMirrored, isDeezer, isSpotifyPublic, isLastfmRadio, isQobuz, isITunesLink)}</div>
|
||
<button class="modal-close-btn" onclick="closeYouTubeDiscoveryModal('${urlHash}')">✕</button>
|
||
</div>
|
||
|
||
<div class="modal-body">
|
||
<div class="progress-section">
|
||
<div class="progress-label">🔍 ${currentMusicSourceName} Discovery Progress</div>
|
||
<div class="progress-bar-container">
|
||
<div class="progress-bar-fill" id="youtube-discovery-progress-${urlHash}" style="width: 0%;"></div>
|
||
</div>
|
||
<div class="progress-text" id="youtube-discovery-progress-text-${urlHash}">${getInitialProgressText(state.phase, isTidal, isBeatport, isListenBrainz)}</div>
|
||
</div>
|
||
|
||
<div class="discovery-table-container">
|
||
<table class="discovery-table">
|
||
<thead>
|
||
<tr>
|
||
<th>${sourceLabel} Track</th>
|
||
<th>${sourceLabel} Artist</th>
|
||
<th>Status</th>
|
||
<th>${currentMusicSourceName} Track</th>
|
||
<th>${currentMusicSourceName} Artist</th>
|
||
<th>Album</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="youtube-discovery-table-${urlHash}">
|
||
${generateTableRowsFromState(state, urlHash)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<div class="modal-footer-left">
|
||
${getModalActionButtons(urlHash, state.phase, state)}
|
||
</div>
|
||
<div class="modal-footer-right">
|
||
<button class="modal-btn modal-btn-secondary" onclick="closeYouTubeDiscoveryModal('${urlHash}')">🏠 Close</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Discovery Fix Modal (nested inside) -->
|
||
<div class="discovery-fix-modal-overlay hidden" id="discovery-fix-modal-overlay">
|
||
<div class="discovery-fix-modal">
|
||
<div class="discovery-fix-modal-header">
|
||
<h2>Fix Track Match</h2>
|
||
<button class="modal-close-btn" onclick="closeDiscoveryFixModal()">✕</button>
|
||
</div>
|
||
|
||
<div class="discovery-fix-modal-content">
|
||
<!-- Source track info (read-only) -->
|
||
<div class="source-track-info">
|
||
<h3>Source Track</h3>
|
||
<div class="source-track-display">
|
||
<div class="source-field">
|
||
<label>Track:</label>
|
||
<span id="fix-modal-source-track">-</span>
|
||
</div>
|
||
<div class="source-field">
|
||
<label>Artist:</label>
|
||
<span id="fix-modal-source-artist">-</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search inputs (editable) -->
|
||
<div class="search-inputs-section">
|
||
<h3>Search for Match</h3>
|
||
<div class="search-input-group">
|
||
<input type="text"
|
||
id="fix-modal-track-input"
|
||
placeholder="Track name"
|
||
class="fix-modal-input">
|
||
<input type="text"
|
||
id="fix-modal-artist-input"
|
||
placeholder="Artist name"
|
||
class="fix-modal-input">
|
||
<button class="search-btn" onclick="searchDiscoveryFix()">
|
||
🔍 Search
|
||
</button>
|
||
</div>
|
||
|
||
<!-- MBID escape hatch: paste a MusicBrainz recording URL or UUID
|
||
to bypass fuzzy search and resolve directly to that record. -->
|
||
<div class="search-input-group" style="margin-top: 8px;">
|
||
<input type="text"
|
||
id="fix-modal-mbid-input"
|
||
placeholder="Or paste MusicBrainz recording URL / MBID"
|
||
class="fix-modal-input"
|
||
style="flex: 1;">
|
||
<button class="search-btn" onclick="lookupDiscoveryFixByMbid()">
|
||
🔗 Look up
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search results -->
|
||
<div class="search-results-section">
|
||
<h3>Results</h3>
|
||
<div id="fix-modal-results" class="fix-modal-results">
|
||
<!-- Auto-populated on modal open, updated on search -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="discovery-fix-modal-footer">
|
||
<button class="modal-btn secondary" onclick="closeDiscoveryFixModal()">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 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_qobuz_playlist) {
|
||
startQobuzSyncPolling(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 isQobuz = state && state.is_qobuz_playlist;
|
||
const isDeezer = state && state.is_deezer_playlist;
|
||
const isSpotifyPublic = state && state.is_spotify_public_playlist;
|
||
const isITunesLink = state && state.is_itunes_link_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 = ` <span class="wing-it-wrap"><button class="modal-btn wing-it-btn" onclick="_toggleWingItDropdown(this, '${urlHash}')">⚡ Wing It</button></span>`;
|
||
|
||
if (isListenBrainz) {
|
||
return `<button class="modal-btn modal-btn-primary" onclick="startListenBrainzDiscovery('${urlHash}')">🔍 Start Discovery</button>${wingItBtn}`;
|
||
} else {
|
||
return `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDiscovery('${urlHash}')">🔍 Start Discovery</button>${wingItBtn}`;
|
||
}
|
||
} else {
|
||
// Discovering phase - show progress
|
||
return `<div class="modal-info">🔍 Discovering ${currentMusicSourceName} matches...</div>`;
|
||
}
|
||
|
||
case 'discovered':
|
||
case 'downloading':
|
||
case 'download_complete':
|
||
// Only show buttons if we actually have discovery data
|
||
if (!hasDiscoveryResults) {
|
||
return `<div class="modal-info">⚠️ No discovery results available. Try starting discovery again.</div>`;
|
||
}
|
||
|
||
let buttons = '';
|
||
|
||
// Only show sync button if there are Spotify matches (and not standalone mode)
|
||
if (hasSpotifyMatches && !_isSoulsyncStandalone) {
|
||
if (isListenBrainz) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startListenBrainzPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isTidal) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startTidalPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isQobuz) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startQobuzPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isDeezer) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startDeezerPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isSpotifyPublic) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startSpotifyPublicPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isITunesLink) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startITunesLinkPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isBeatport) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startBeatportPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubePlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
}
|
||
}
|
||
|
||
// Only show download button if we have matches or a converted playlist ID
|
||
if (hasSpotifyMatches || hasConvertedPlaylistId) {
|
||
if (isListenBrainz) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isTidal) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startTidalDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isQobuz) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startQobuzDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isDeezer) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startDeezerDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isSpotifyPublic) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startSpotifyPublicDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isITunesLink) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startITunesLinkDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isBeatport) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startBeatportDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
}
|
||
}
|
||
|
||
// 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 += `<button class="modal-btn modal-btn-secondary" onclick="retryFailedMirroredDiscovery('${urlHash}')">🔄 Retry Failed (${failedCount})</button>`;
|
||
}
|
||
}
|
||
|
||
// Rediscover button — reset and re-run discovery (only for sources with reset endpoints)
|
||
if (isBeatport) {
|
||
buttons += `<button class="modal-btn modal-btn-secondary" onclick="resetBeatportChart('${urlHash}')">🔄 Rediscover</button>`;
|
||
} else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic && !isITunesLink) {
|
||
buttons += `<button class="modal-btn modal-btn-secondary" onclick="resetYouTubePlaylist('${urlHash}')">🔄 Rediscover</button>`;
|
||
}
|
||
|
||
// Wing It button — available in discovered phase
|
||
buttons += ` <span class="wing-it-wrap"><button class="modal-btn wing-it-btn" onclick="_toggleWingItDropdown(this, '${urlHash}')">⚡ Wing It</button></span>`;
|
||
|
||
if (!buttons || buttons.trim().startsWith('<button class="modal-btn wing-it-btn"')) {
|
||
buttons = `<div class="modal-info">ℹ️ No Spotify matches found.</div>` + buttons;
|
||
}
|
||
|
||
return buttons;
|
||
|
||
case 'syncing':
|
||
if (isListenBrainz) {
|
||
return `
|
||
<button class="modal-btn modal-btn-danger" onclick="cancelYouTubeSync('${urlHash}')">❌ Cancel Sync</button>
|
||
<div class="playlist-modal-sync-status" id="listenbrainz-sync-status-${urlHash}" style="display: flex;">
|
||
<span class="sync-stat total-tracks">♪ <span id="listenbrainz-total-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ <span id="listenbrainz-matched-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ <span id="listenbrainz-failed-${urlHash}">0</span></span>
|
||
<span class="sync-stat percentage">(<span id="listenbrainz-percentage-${urlHash}">0</span>%)</span>
|
||
</div>
|
||
`;
|
||
} else if (isTidal) {
|
||
return `
|
||
<button class="modal-btn modal-btn-danger" onclick="cancelTidalSync('${urlHash}')">❌ Cancel Sync</button>
|
||
<div class="playlist-modal-sync-status" id="tidal-sync-status-${urlHash}" style="display: flex;">
|
||
<span class="sync-stat total-tracks">♪ <span id="tidal-total-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ <span id="tidal-matched-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ <span id="tidal-failed-${urlHash}">0</span></span>
|
||
<span class="sync-stat percentage">(<span id="tidal-percentage-${urlHash}">0</span>%)</span>
|
||
</div>
|
||
`;
|
||
} else if (isQobuz) {
|
||
// Qobuz reuses the tidal-* id namespace for the sync-status row
|
||
// because updateQobuzModalSyncProgress targets those exact IDs.
|
||
// Keeping the markup byte-identical to Tidal avoids a separate
|
||
// CSS pass; only the cancel handler differs.
|
||
return `
|
||
<button class="modal-btn modal-btn-danger" onclick="cancelQobuzSync('${urlHash}')">❌ Cancel Sync</button>
|
||
<div class="playlist-modal-sync-status" id="tidal-sync-status-${urlHash}" style="display: flex;">
|
||
<span class="sync-stat total-tracks">♪ <span id="tidal-total-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ <span id="tidal-matched-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ <span id="tidal-failed-${urlHash}">0</span></span>
|
||
<span class="sync-stat percentage">(<span id="tidal-percentage-${urlHash}">0</span>%)</span>
|
||
</div>
|
||
`;
|
||
} else if (isDeezer) {
|
||
return `
|
||
<button class="modal-btn modal-btn-danger" onclick="cancelDeezerSync('${urlHash}')">❌ Cancel Sync</button>
|
||
<div class="playlist-modal-sync-status" id="deezer-sync-status-${urlHash}" style="display: flex;">
|
||
<span class="sync-stat total-tracks">♪ <span id="deezer-total-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ <span id="deezer-matched-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ <span id="deezer-failed-${urlHash}">0</span></span>
|
||
<span class="sync-stat percentage">(<span id="deezer-percentage-${urlHash}">0</span>%)</span>
|
||
</div>
|
||
`;
|
||
} else if (isSpotifyPublic) {
|
||
return `
|
||
<button class="modal-btn modal-btn-danger" onclick="cancelSpotifyPublicSync('${urlHash}')">❌ Cancel Sync</button>
|
||
<div class="playlist-modal-sync-status" id="spotify-public-sync-status-${urlHash}" style="display: flex;">
|
||
<span class="sync-stat total-tracks">♪ <span id="spotify-public-total-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ <span id="spotify-public-matched-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ <span id="spotify-public-failed-${urlHash}">0</span></span>
|
||
<span class="sync-stat percentage">(<span id="spotify-public-percentage-${urlHash}">0</span>%)</span>
|
||
</div>
|
||
`;
|
||
} else if (isITunesLink) {
|
||
return `
|
||
<button class="modal-btn modal-btn-danger" onclick="cancelITunesLinkSync('${urlHash}')">❌ Cancel Sync</button>
|
||
<div class="playlist-modal-sync-status" id="itunes-link-sync-status-${urlHash}" style="display: flex;">
|
||
<span class="sync-stat total-tracks">♪ <span id="itunes-link-total-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ <span id="itunes-link-matched-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ <span id="itunes-link-failed-${urlHash}">0</span></span>
|
||
<span class="sync-stat percentage">(<span id="itunes-link-percentage-${urlHash}">0</span>%)</span>
|
||
</div>
|
||
`;
|
||
} else if (isBeatport) {
|
||
return `
|
||
<button class="modal-btn modal-btn-danger" onclick="cancelBeatportSync('${urlHash}')">❌ Cancel Sync</button>
|
||
<div class="playlist-modal-sync-status" id="beatport-sync-status-${urlHash}" style="display: flex;">
|
||
<span class="sync-stat total-tracks">♪ <span id="beatport-total-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ <span id="beatport-matched-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ <span id="beatport-failed-${urlHash}">0</span></span>
|
||
<span class="sync-stat percentage">(<span id="beatport-percentage-${urlHash}">0</span>%)</span>
|
||
</div>
|
||
`;
|
||
} else {
|
||
return `
|
||
<button class="modal-btn modal-btn-danger" onclick="cancelYouTubeSync('${urlHash}')">❌ Cancel Sync</button>
|
||
<div class="playlist-modal-sync-status" id="youtube-sync-status-${urlHash}" style="display: flex;">
|
||
<span class="sync-stat total-tracks">♪ <span id="youtube-total-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ <span id="youtube-matched-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ <span id="youtube-failed-${urlHash}">0</span></span>
|
||
<span class="sync-stat percentage">(<span id="youtube-percentage-${urlHash}">0</span>%)</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
case 'sync_complete':
|
||
let syncCompleteButtons = '';
|
||
|
||
// Only show sync button if there are Spotify matches (and not standalone mode)
|
||
if (hasSpotifyMatches && !_isSoulsyncStandalone) {
|
||
if (isListenBrainz) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startListenBrainzPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isTidal) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startTidalPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isQobuz) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startQobuzPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isSpotifyPublic) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startSpotifyPublicPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isITunesLink) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startITunesLinkPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isBeatport) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startBeatportPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubePlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
}
|
||
}
|
||
|
||
// Only show download button if we have matches or a converted playlist ID
|
||
if (hasSpotifyMatches || hasConvertedPlaylistId) {
|
||
if (isListenBrainz) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isTidal) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startTidalDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isQobuz) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startQobuzDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isSpotifyPublic) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startSpotifyPublicDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isITunesLink) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startITunesLinkDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isBeatport) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startBeatportDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
}
|
||
}
|
||
|
||
// Rediscover button (only for sources with reset endpoints)
|
||
if (isBeatport) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-secondary" onclick="resetBeatportChart('${urlHash}')">🔄 Rediscover</button>`;
|
||
} else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic && !isITunesLink) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-secondary" onclick="resetYouTubePlaylist('${urlHash}')">🔄 Rediscover</button>`;
|
||
}
|
||
|
||
// Wing It button
|
||
syncCompleteButtons += ` <span class="wing-it-wrap"><button class="modal-btn wing-it-btn" onclick="_toggleWingItDropdown(this, '${urlHash}')">⚡ Wing It</button></span>`;
|
||
|
||
return syncCompleteButtons;
|
||
|
||
case 'download_complete':
|
||
// Same options as sync_complete — allow re-sync, download missing, and reset
|
||
let dlCompleteButtons = '';
|
||
|
||
if (hasSpotifyMatches) {
|
||
if (isListenBrainz) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startListenBrainzPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isTidal) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startTidalPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isQobuz) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startQobuzPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isDeezer) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startDeezerPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isSpotifyPublic) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startSpotifyPublicPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isITunesLink) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startITunesLinkPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else if (isBeatport) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startBeatportPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubePlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
}
|
||
}
|
||
|
||
if (hasSpotifyMatches || hasConvertedPlaylistId) {
|
||
if (isListenBrainz) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isTidal) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startTidalDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isQobuz) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startQobuzDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isDeezer) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startDeezerDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isSpotifyPublic) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startSpotifyPublicDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isITunesLink) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startITunesLinkDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else if (isBeatport) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startBeatportDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
}
|
||
}
|
||
|
||
// Rediscover button (only for sources with reset endpoints)
|
||
if (isBeatport) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-secondary" onclick="resetBeatportChart('${urlHash}')">🔄 Rediscover</button>`;
|
||
} else if (!isListenBrainz && !isTidal && !isQobuz && !isDeezer && !isSpotifyPublic && !isITunesLink) {
|
||
dlCompleteButtons += `<button class="modal-btn modal-btn-secondary" onclick="resetYouTubePlaylist('${urlHash}')">🔄 Rediscover</button>`;
|
||
}
|
||
|
||
return dlCompleteButtons;
|
||
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false, isSpotifyPublic = false, isLastfmRadio = false, isQobuz = false, isITunesLink = false) {
|
||
const source = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'Spotify' : (isITunesLink ? 'iTunes' : (isDeezer ? 'Deezer' : (isLastfmRadio ? 'Last.fm Radio' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isQobuz ? 'Qobuz' : (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 isQobuz = state.is_qobuz_playlist;
|
||
const isDeezer = state.is_deezer_playlist;
|
||
const isSpotifyPublic = state.is_spotify_public_playlist;
|
||
const isITunesLink = state.is_itunes_link_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' : (isITunesLink ? 'itunes_link' : (isDeezer ? 'deezer' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isQobuz ? 'qobuz' : (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 `
|
||
<tr id="discovery-row-${urlHash}-${result.index}">
|
||
<td class="yt-track">${trackName}</td>
|
||
<td class="yt-artist">${artistName}</td>
|
||
<td class="discovery-status ${result.status_class}">${result.status}</td>
|
||
<td class="spotify-track">${result.spotify_track || '-'}</td>
|
||
<td class="spotify-artist">${result.spotify_artist || '-'}</td>
|
||
<td class="spotify-album">${result.spotify_album || '-'}</td>
|
||
<td class="discovery-actions">${generateDiscoveryActionButton(result, urlHash, platform)}</td>
|
||
</tr>
|
||
`;
|
||
}).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 `
|
||
<tr id="discovery-row-${urlHash}-${index}">
|
||
<td class="yt-track">${trackName}</td>
|
||
<td class="yt-artist">${artistName}</td>
|
||
<td class="discovery-status">🔍 Pending...</td>
|
||
<td class="spotify-track">-</td>
|
||
<td class="spotify-artist">-</td>
|
||
<td class="spotify-album">-</td>
|
||
<td class="discovery-actions">-</td>
|
||
</tr>
|
||
`;
|
||
}).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 `<button class="fix-match-btn"
|
||
onclick="openDiscoveryFixModal('${platform}', '${identifier}', ${result.index})"
|
||
title="Manually search for this track">
|
||
🔧 Fix
|
||
</button>`;
|
||
}
|
||
|
||
// For wing-it fallbacks, show fix button so user can find a real match
|
||
if (isWingIt) {
|
||
return `<button class="fix-match-btn"
|
||
onclick="openDiscoveryFixModal('${platform}', '${identifier}', ${result.index})"
|
||
title="Search for a proper metadata match">
|
||
🔧 Fix
|
||
</button>`;
|
||
}
|
||
|
||
// For found matches, show re-match and unmatch buttons
|
||
if (isFound) {
|
||
return `<button class="rematch-btn"
|
||
onclick="openDiscoveryFixModal('${platform}', '${identifier}', ${result.index})"
|
||
title="Change this match">
|
||
↻
|
||
</button><button class="rematch-btn" style="margin-left:4px;color:#ff6b6b"
|
||
onclick="unmatchDiscoveryTrack('${platform}', '${identifier}', ${result.index})"
|
||
title="Remove this match">
|
||
✕
|
||
</button>`;
|
||
}
|
||
|
||
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_itunes_link_playlist ? 'itunes_link' :
|
||
(state?.is_deezer_playlist ? 'deezer' :
|
||
(state?.is_listenbrainz_playlist ? 'listenbrainz' :
|
||
(state?.is_tidal_playlist ? 'tidal' :
|
||
(state?.is_qobuz_playlist ? 'qobuz' :
|
||
(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 isQobuz = state.is_qobuz_playlist;
|
||
const isDeezer = state.is_deezer_playlist;
|
||
const isSpotifyPublic = state.is_spotify_public_playlist;
|
||
const isITunesLink = state.is_itunes_link_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' : (isITunesLink ? 'iTunes Link' : (isDeezer ? 'Deezer' : (isQobuz ? 'Qobuz' : (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 (isITunesLink) {
|
||
const itunesUrlHash = state.itunes_link_playlist_id || null;
|
||
if (itunesUrlHash && itunesLinkPlaylistStates[itunesUrlHash]) {
|
||
const preservedData = {
|
||
playlist: itunesLinkPlaylistStates[itunesUrlHash].playlist,
|
||
discovery_results: itunesLinkPlaylistStates[itunesUrlHash].discovery_results,
|
||
spotify_matches: itunesLinkPlaylistStates[itunesUrlHash].spotify_matches,
|
||
discovery_progress: itunesLinkPlaylistStates[itunesUrlHash].discovery_progress,
|
||
convertedSpotifyPlaylistId: itunesLinkPlaylistStates[itunesUrlHash].convertedSpotifyPlaylistId
|
||
};
|
||
|
||
delete itunesLinkPlaylistStates[itunesUrlHash].download_process_id;
|
||
delete itunesLinkPlaylistStates[itunesUrlHash].phase;
|
||
|
||
Object.assign(itunesLinkPlaylistStates[itunesUrlHash], preservedData);
|
||
itunesLinkPlaylistStates[itunesUrlHash].phase = 'discovered';
|
||
|
||
updateITunesLinkCardPhase(itunesUrlHash, 'discovered');
|
||
|
||
try {
|
||
fetch(`/api/itunes-link/update_phase/${itunesUrlHash}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ phase: 'discovered' })
|
||
});
|
||
} catch (error) {
|
||
console.warn('Error updating backend iTunes Link 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 (isQobuz) {
|
||
// Qobuz: same shape as Tidal — preserve discovery data,
|
||
// reset phase to 'discovered', push update to backend.
|
||
const qobuzPlaylistId = state.qobuz_playlist_id || null;
|
||
if (qobuzPlaylistId && qobuzPlaylistStates[qobuzPlaylistId]) {
|
||
const preservedData = {
|
||
playlist: qobuzPlaylistStates[qobuzPlaylistId].playlist,
|
||
discovery_results: qobuzPlaylistStates[qobuzPlaylistId].discovery_results,
|
||
spotify_matches: qobuzPlaylistStates[qobuzPlaylistId].spotify_matches,
|
||
discovery_progress: qobuzPlaylistStates[qobuzPlaylistId].discovery_progress,
|
||
convertedSpotifyPlaylistId: qobuzPlaylistStates[qobuzPlaylistId].convertedSpotifyPlaylistId
|
||
};
|
||
|
||
delete qobuzPlaylistStates[qobuzPlaylistId].download_process_id;
|
||
delete qobuzPlaylistStates[qobuzPlaylistId].phase;
|
||
|
||
Object.assign(qobuzPlaylistStates[qobuzPlaylistId], preservedData);
|
||
qobuzPlaylistStates[qobuzPlaylistId].phase = 'discovered';
|
||
|
||
updateQobuzCardPhase(qobuzPlaylistId, 'discovered');
|
||
|
||
try {
|
||
fetch(`/api/qobuz/update_phase/${qobuzPlaylistId}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ phase: 'discovered' })
|
||
});
|
||
} catch (error) {
|
||
console.warn('⚠️ Error updating backend Qobuz 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 = `
|
||
<div class="playlist-card-sync-status">
|
||
<span class="sync-stat total-tracks">♪ ${total}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ ${matched}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ ${failed}</span>
|
||
<span class="sync-stat percentage">(${percentage}%)</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 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', 'itunes-link', '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 isQobuz = state.is_qobuz_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}` : (isQobuz ? `qobuz_${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
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Auto-mirror a ListenBrainz playlist into the mirrored_playlists
|
||
* table after discovery completes. Pattern parity with Tidal —
|
||
* Tidal mirrors on tab load with raw tracks, then discovery enriches;
|
||
* LB tracks only have provider IDs after discovery, so we mirror at
|
||
* the end. Idempotent (UPSERT on source + source_playlist_id +
|
||
* profile_id), so calling it twice is a no-op.
|
||
*
|
||
* Rotating-series collapse: if the playlist title belongs to a
|
||
* known LB series (Weekly Jams, Weekly Exploration, Top Discoveries,
|
||
* Top Missed Recordings), the mirror is created under a synthetic
|
||
* ``source_playlist_id`` like ``lb_weekly_jams_<user>`` with a
|
||
* canonical name. The next week / year UPSERTs into the same row,
|
||
* so users get one rolling mirror per series instead of accumulating
|
||
* one per period. Non-series LB playlists (user-created,
|
||
* collaborative, Last.fm radios for a specific seed) continue to
|
||
* mirror under their per-playlist MBID.
|
||
*/
|
||
async function _mirrorListenBrainzAfterDiscovery(playlistMbid) {
|
||
try {
|
||
const state = listenbrainzPlaylistStates[playlistMbid];
|
||
if (!state || !state.playlist) return;
|
||
if (typeof mirrorPlaylist !== 'function') return;
|
||
|
||
const results = state.discovery_results || [];
|
||
if (!results.length) return;
|
||
|
||
const tracks = results
|
||
.filter(r => r && r.spotify_data && r.spotify_data.id)
|
||
.map(r => {
|
||
const sp = r.spotify_data;
|
||
const artistName = Array.isArray(sp.artists)
|
||
? (typeof sp.artists[0] === 'object' ? sp.artists[0].name : sp.artists[0])
|
||
: (sp.artists || '');
|
||
const albumName = (sp.album && typeof sp.album === 'object')
|
||
? sp.album.name : (sp.album || '');
|
||
const albumImage = (sp.album && sp.album.images && sp.album.images[0])
|
||
? sp.album.images[0].url : (sp.image_url || null);
|
||
return {
|
||
track_name: sp.name || '',
|
||
artist_name: artistName || '',
|
||
album_name: albumName || '',
|
||
duration_ms: sp.duration_ms || 0,
|
||
image_url: albumImage,
|
||
source_track_id: sp.id || '',
|
||
extra_data: JSON.stringify({
|
||
discovered: true,
|
||
provider: sp.source || 'spotify',
|
||
confidence: r.confidence || 1.0,
|
||
matched_data: sp,
|
||
}),
|
||
};
|
||
});
|
||
|
||
if (!tracks.length) {
|
||
console.warn(`🪞 [LB Mirror] No matched tracks in '${state.playlist.name}', skipping mirror`);
|
||
return;
|
||
}
|
||
|
||
// Last.fm Radio playlists live in the same listenbrainz_playlists
|
||
// table but are persisted by ``save_lastfm_radio_playlist`` with
|
||
// a "Last.fm Radio: <seed>" title prefix and ``playlist_type='lastfm_radio'``.
|
||
// Route them to ``source='lastfm'`` so the Auto-Sync manager
|
||
// groups them under the Last.fm Radio section + the cascade-
|
||
// delete hook targets the right mirror source.
|
||
const rawTitle = state.playlist.name || 'ListenBrainz Playlist';
|
||
let mirrorSource = rawTitle.startsWith('Last.fm Radio:') ? 'lastfm' : 'listenbrainz';
|
||
let mirrorSourcePlaylistId = playlistMbid;
|
||
let mirrorName = rawTitle;
|
||
|
||
// Rolling-series detection — backend tells us whether the title
|
||
// belongs to a known rotating LB series. If so, collapse this
|
||
// mirror onto a synthetic id + canonical name so per-week /
|
||
// per-year duplicates roll up into one row.
|
||
try {
|
||
const seriesResp = await fetch(
|
||
`/api/listenbrainz/series-detect?title=${encodeURIComponent(rawTitle)}`
|
||
);
|
||
if (seriesResp.ok) {
|
||
const seriesData = await seriesResp.json();
|
||
if (seriesData && seriesData.matched) {
|
||
mirrorSource = seriesData.source || mirrorSource;
|
||
mirrorSourcePlaylistId = seriesData.series_id || mirrorSourcePlaylistId;
|
||
mirrorName = seriesData.canonical_name || mirrorName;
|
||
console.log(
|
||
`🔁 [LB Series] '${rawTitle}' rolled into '${mirrorName}' ` +
|
||
`(series id: ${mirrorSourcePlaylistId})`
|
||
);
|
||
}
|
||
}
|
||
} catch (_) {
|
||
// Non-fatal — fall through to per-playlist mirror id.
|
||
}
|
||
|
||
const ownerFallback = mirrorSource === 'lastfm' ? 'Last.fm' : 'ListenBrainz';
|
||
mirrorPlaylist(
|
||
mirrorSource,
|
||
mirrorSourcePlaylistId,
|
||
mirrorName,
|
||
tracks,
|
||
{
|
||
owner: state.playlist.creator || ownerFallback,
|
||
description: state.playlist.description || '',
|
||
image_url: state.playlist.image_url || '',
|
||
}
|
||
);
|
||
console.log(`🪞 [${mirrorSource} Mirror] Mirrored '${mirrorName}' with ${tracks.length} matched tracks`);
|
||
} catch (err) {
|
||
console.warn('LB mirror-after-discovery failed:', err);
|
||
}
|
||
}
|
||
|
||
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';
|
||
// Mirror matched tracks → mirrored_playlists table so the
|
||
// playlist participates in Auto-Sync schedules just like
|
||
// Tidal / Qobuz / Spotify mirrors do.
|
||
_mirrorListenBrainzAfterDiscovery(playlistMbid);
|
||
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);
|
||
// Mirror matched tracks → mirrored_playlists table so
|
||
// the playlist participates in Auto-Sync schedules.
|
||
_mirrorListenBrainzAfterDiscovery(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;
|
||
}
|
||
|
||
// ============================================================================
|
||
|