soulsync/webui/static/script.js
Broque Thomas 219a36c05b Improve wishlist processing and modal sync logic
Sanitizes wishlist track data for consistent backend/frontend handling, enhances wishlist process state reporting for better UI sync, and refactors frontend modal logic to prevent auto-show conflicts and ensure user-driven visibility. Also improves polling and rehydration to keep frontend state in sync with server-side auto-processing.
2025-09-09 18:28:29 -07:00

11757 lines
No EOL
473 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SoulSync WebUI JavaScript - Replicating PyQt6 GUI Functionality
// Global state management
let currentPage = 'dashboard';
let currentTrack = null;
let isPlaying = false;
let mediaPlayerExpanded = false;
let donationAddressesVisible = false;
let searchResults = [];
let currentStream = {
status: 'stopped',
progress: 0,
track: null
};
// Streaming state management (enhanced functionality)
let streamStatusPoller = null;
let audioPlayer = null;
let streamPollingRetries = 0;
let streamPollingInterval = 1000; // Start with 1-second polling
const maxStreamPollingRetries = 10;
let allSearchResults = [];
let currentFilterType = 'all';
let currentFilterFormat = 'all';
let currentSortBy = 'quality_score';
let isSortReversed = false;
let searchAbortController = null;
let dbStatsInterval = null;
let dbUpdateStatusInterval = null;
let wishlistCountInterval = null;
// --- Add these globals for the Sync Page ---
let spotifyPlaylists = [];
let selectedPlaylists = new Set();
let activeSyncPollers = {}; // Key: playlist_id, Value: intervalId
let playlistTrackCache = {}; // Key: playlist_id, Value: tracks array
let spotifyPlaylistsLoaded = false;
let activeDownloadProcesses = {};
let sequentialSyncManager = null;
// --- YouTube Playlist State Management ---
let youtubePlaylistStates = {}; // Key: url_hash, Value: playlist state
let activeYouTubePollers = {}; // Key: url_hash, Value: intervalId
// --- Tidal Playlist State Management (Similar to YouTube but loads from API like Spotify) ---
let tidalPlaylists = [];
let tidalPlaylistStates = {}; // Key: playlist_id, Value: playlist state with phases
let tidalPlaylistsLoaded = false;
// --- Artists Page State Management ---
let artistsPageState = {
currentView: 'search', // 'search', 'results', 'detail'
searchQuery: '',
searchResults: [],
selectedArtist: null,
artistDiscography: {
albums: [],
singles: []
},
cache: {
searches: {}, // Cache search results by query
discography: {}, // Cache discography by artist ID
colors: {}, // Cache extracted colors by image URL
completionData: {} // Cache completion data by artist ID
},
isInitialized: false // Track if the page has been initialized
};
// --- Artist Downloads Management State ---
let artistDownloadBubbles = {}; // Track artist download bubbles: artistId -> { artist, downloads: [], element }
let artistDownloadModalOpen = false; // Track if artist download modal is open
let downloadsUpdateTimeout = null; // Debounce downloads section updates
let artistsSearchTimeout = null;
let artistsSearchController = null;
// --- Wishlist Modal Persistence State Management ---
const WishlistModalState = {
// Track if wishlist modal was visible before page refresh
setVisible: function() {
localStorage.setItem('wishlist_modal_visible', 'true');
console.log('📱 [Modal State] Wishlist modal marked as visible in localStorage');
},
setHidden: function() {
localStorage.setItem('wishlist_modal_visible', 'false');
console.log('📱 [Modal State] Wishlist modal marked as hidden in localStorage');
},
wasVisible: function() {
const visible = localStorage.getItem('wishlist_modal_visible') === 'true';
console.log(`📱 [Modal State] Checking if wishlist modal was visible: ${visible}`);
return visible;
},
clear: function() {
localStorage.removeItem('wishlist_modal_visible');
console.log('📱 [Modal State] Cleared wishlist modal visibility state');
},
// Track if user manually closed the modal during auto-processing
setUserClosed: function() {
localStorage.setItem('wishlist_modal_user_closed', 'true');
console.log('📱 [Modal State] User manually closed wishlist modal during auto-processing');
},
clearUserClosed: function() {
localStorage.removeItem('wishlist_modal_user_closed');
console.log('📱 [Modal State] Cleared user closed state');
},
wasUserClosed: function() {
const closed = localStorage.getItem('wishlist_modal_user_closed') === 'true';
console.log(`📱 [Modal State] Checking if user closed modal: ${closed}`);
return closed;
}
};
// Sequential Sync Manager Class
class SequentialSyncManager {
constructor() {
this.queue = [];
this.currentIndex = 0;
this.isRunning = false;
this.startTime = null;
}
start(playlistIds) {
if (this.isRunning) {
console.warn('Sequential sync already running');
return;
}
// Convert playlist IDs to ordered array (maintain display order)
this.queue = Array.from(playlistIds);
this.currentIndex = 0;
this.isRunning = true;
this.startTime = Date.now();
console.log(`🚀 Starting sequential sync for ${this.queue.length} playlists:`, this.queue);
this.updateUI();
this.syncNext();
}
async syncNext() {
if (this.currentIndex >= this.queue.length) {
this.complete();
return;
}
const playlistId = this.queue[this.currentIndex];
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
console.log(`🔄 Sequential sync: Processing playlist ${this.currentIndex + 1}/${this.queue.length}: ${playlist?.name || playlistId}`);
this.updateUI();
try {
// Use existing single sync function
await startPlaylistSync(playlistId);
// Wait for sync to complete by monitoring the poller
await this.waitForSyncCompletion(playlistId);
} catch (error) {
console.error(`❌ Sequential sync: Failed to sync playlist ${playlistId}:`, error);
showToast(`Failed to sync "${playlist?.name || playlistId}": ${error.message}`, 'error');
}
// Move to next playlist
this.currentIndex++;
setTimeout(() => this.syncNext(), 1000); // Small delay between syncs
}
async waitForSyncCompletion(playlistId) {
return new Promise((resolve) => {
// Monitor the existing sync poller for completion
const checkCompletion = () => {
if (!activeSyncPollers[playlistId]) {
// Poller stopped = sync completed
resolve();
return;
}
// Check again in 1 second
setTimeout(checkCompletion, 1000);
};
checkCompletion();
});
}
complete() {
const duration = ((Date.now() - this.startTime) / 1000).toFixed(1);
const completedCount = this.queue.length;
console.log(`🏁 Sequential sync completed in ${duration}s`);
this.isRunning = false;
this.queue = [];
this.currentIndex = 0;
this.startTime = null;
// Re-enable playlist selection
disablePlaylistSelection(false);
this.updateUI();
updateRefreshButtonState(); // Refresh button state after completion
showToast(`Sequential sync completed for ${completedCount} playlists in ${duration}s`, 'success');
}
cancel() {
if (!this.isRunning) return;
console.log('🛑 Cancelling sequential sync');
this.isRunning = false;
this.queue = [];
this.currentIndex = 0;
this.startTime = null;
// Re-enable playlist selection
disablePlaylistSelection(false);
this.updateUI();
updateRefreshButtonState(); // Refresh button state after cancellation
showToast('Sequential sync cancelled', 'info');
}
updateUI() {
const startSyncBtn = document.getElementById('start-sync-btn');
const selectionInfo = document.getElementById('selection-info');
if (!this.isRunning) {
// Reset to normal state
if (startSyncBtn) {
startSyncBtn.textContent = 'Start Sync';
startSyncBtn.disabled = selectedPlaylists.size === 0;
}
if (selectionInfo) {
const count = selectedPlaylists.size;
selectionInfo.textContent = count === 0
? 'Select playlists to sync'
: `${count} playlist${count > 1 ? 's' : ''} selected`;
}
} else {
// Show sequential sync status
if (startSyncBtn) {
startSyncBtn.textContent = 'Cancel Sequential Sync';
startSyncBtn.disabled = false;
}
if (selectionInfo) {
const current = this.currentIndex + 1;
const total = this.queue.length;
const currentPlaylist = spotifyPlaylists.find(p => p.id === this.queue[this.currentIndex]);
selectionInfo.textContent = `Syncing ${current}/${total}: ${currentPlaylist?.name || 'Unknown'}`;
}
}
}
}
// API endpoints
const API = {
status: '/status',
config: '/config',
settings: '/api/settings',
testConnection: '/api/test-connection',
playlists: '/api/playlists',
sync: '/api/sync',
search: '/api/search',
artists: '/api/artists',
activity: '/api/activity',
stream: {
start: '/api/stream/start',
status: '/api/stream/status',
toggle: '/api/stream/toggle',
stop: '/api/stream/stop'
}
};
// ===============================
// INITIALIZATION
// ===============================
document.addEventListener('DOMContentLoaded', function() {
console.log('SoulSync WebUI initializing...');
// Initialize components
initializeNavigation();
initializeMediaPlayer();
initializeDonationWidget();
initializeSyncPage();
// Start periodic updates
updateServiceStatus();
setInterval(updateServiceStatus, 5000); // Every 5 seconds
// Start always-on download polling (batched, minimal overhead)
startGlobalDownloadPolling();
// Load initial data
loadInitialData();
// Handle window resize to re-check track title scrolling
window.addEventListener('resize', function() {
if (currentTrack) {
const trackTitleElement = document.getElementById('track-title');
const trackTitle = currentTrack.title || 'Unknown Track';
setTimeout(() => {
checkAndEnableScrolling(trackTitleElement, trackTitle);
}, 100); // Small delay to allow layout to settle
}
});
console.log('SoulSync WebUI initialized successfully!');
});
// ===============================
// NAVIGATION SYSTEM
// ===============================
function initializeNavigation() {
const navButtons = document.querySelectorAll('.nav-button');
navButtons.forEach(button => {
button.addEventListener('click', () => {
const page = button.getAttribute('data-page');
navigateToPage(page);
});
});
}
function navigateToPage(pageId) {
if (pageId === currentPage) return;
// Update navigation buttons
document.querySelectorAll('.nav-button').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-page="${pageId}"]`).classList.add('active');
// Update pages
document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active');
});
document.getElementById(`${pageId}-page`).classList.add('active');
currentPage = pageId;
// Load page-specific data
loadPageData(pageId);
}
// REPLACE your old loadPageData function with this one:
// REPLACE your old loadPageData function with this corrected one
async function loadPageData(pageId) {
try {
// Stop any active polling when navigating away
stopDbStatsPolling();
stopDbUpdatePolling();
stopWishlistCountPolling();
switch (pageId) {
case 'dashboard':
await loadDashboardData();
break;
case 'sync':
initializeSyncPage();
await loadSyncData();
break;
case 'downloads':
initializeSearch();
initializeFilters();
await loadDownloadsData();
break;
case 'artists':
// Only fully initialize if not already initialized
if (!artistsPageState.isInitialized) {
initializeArtistsPage();
} else {
// Just restore state if already initialized
restoreArtistsPageState();
}
break;
case 'settings':
initializeSettings();
await loadSettingsData();
break;
}
} catch (error) {
console.error(`Error loading ${pageId} data:`, error);
showToast(`Failed to load ${pageId} data`, 'error');
}
}
// ===============================
// SERVICE STATUS MONITORING
// ===============================
async function updateServiceStatus() {
try {
const response = await fetch(API.status);
const data = await response.json();
// Update sidebar status indicators
updateStatusIndicator('spotify', data.spotify);
updateStatusIndicator('media-server', data.media_server);
updateStatusIndicator('soulseek', data.soulseek);
// Update media server name
const serverName = data.active_media_server === 'plex' ? 'Plex' : 'Jellyfin';
document.getElementById('media-server-name').textContent = serverName;
} catch (error) {
console.error('Error fetching status:', error);
// Set all to disconnected on error
updateStatusIndicator('spotify', false);
updateStatusIndicator('media-server', false);
updateStatusIndicator('soulseek', false);
}
}
function updateStatusIndicator(service, connected) {
const indicator = document.getElementById(`${service}-indicator`);
const dot = indicator.querySelector('.status-dot');
if (connected) {
dot.classList.remove('disconnected');
dot.classList.add('connected');
} else {
dot.classList.remove('connected');
dot.classList.add('disconnected');
}
}
// ===============================
// MEDIA PLAYER FUNCTIONALITY
// ===============================
function initializeMediaPlayer() {
const trackTitle = document.getElementById('track-title');
const playButton = document.getElementById('play-button');
const stopButton = document.getElementById('stop-button');
const volumeSlider = document.getElementById('volume-slider');
// Initialize HTML5 audio player
audioPlayer = document.getElementById('audio-player');
if (audioPlayer) {
// Set up audio event listeners
audioPlayer.addEventListener('timeupdate', updateAudioProgress);
audioPlayer.addEventListener('ended', onAudioEnded);
audioPlayer.addEventListener('error', onAudioError);
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
audioPlayer.addEventListener('canplay', onAudioCanPlay);
// Set initial volume
audioPlayer.volume = 0.7; // 70%
volumeSlider.value = 70;
}
// Track title click - toggle expansion
trackTitle.addEventListener('click', toggleMediaPlayerExpansion);
// Media controls
playButton.addEventListener('click', handlePlayPause);
stopButton.addEventListener('click', handleStop);
volumeSlider.addEventListener('input', handleVolumeChange);
// Progress bar controls
const progressBar = document.getElementById('progress-bar');
if (progressBar) {
// Handle seeking
progressBar.addEventListener('input', handleProgressBarChange);
progressBar.addEventListener('mousedown', () => {
progressBar.dataset.seeking = 'true';
});
progressBar.addEventListener('mouseup', () => {
delete progressBar.dataset.seeking;
});
}
// Update volume slider styling
volumeSlider.addEventListener('input', updateVolumeSliderAppearance);
}
function toggleMediaPlayerExpansion() {
if (!currentTrack) return;
const mediaPlayer = document.getElementById('media-player');
const expandedContent = document.getElementById('media-expanded');
const noTrackMessage = document.getElementById('no-track-message');
mediaPlayerExpanded = !mediaPlayerExpanded;
if (mediaPlayerExpanded) {
mediaPlayer.style.minHeight = '145px';
expandedContent.classList.remove('hidden');
noTrackMessage.classList.add('hidden');
} else {
mediaPlayer.style.minHeight = '85px';
expandedContent.classList.add('hidden');
}
}
function extractTrackTitle(filename) {
if (!filename) return null;
// Remove file extension
let title = filename.replace(/\.[^/.]+$/, '');
// Remove path components, keep only the filename
title = title.split('/').pop().split('\\').pop();
// Clean up common filename patterns
title = title
.replace(/^\d+\.?\s*/, '') // Remove track numbers at start
.replace(/^\d+\s*-\s*/, '') // Remove "01 - " patterns
.replace(/\s*-\s*\d{4}\s*$/, '') // Remove years at end
.replace(/\s*\[\d+kbps\].*$/, '') // Remove bitrate info
.replace(/\s*\(.*?\)\s*$/, '') // Remove parenthetical info at end
.trim();
return title || null;
}
function setTrackInfo(track) {
currentTrack = track;
const trackTitleElement = document.getElementById('track-title');
const trackTitle = track.title || 'Unknown Track';
// Set up the HTML structure for scrolling
trackTitleElement.innerHTML = `<span class="title-text">${escapeHtml(trackTitle)}</span>`;
document.getElementById('artist-name').textContent = track.artist || 'Unknown Artist';
document.getElementById('album-name').textContent = track.album || 'Unknown Album';
// Check if title needs scrolling (similar to GUI app)
setTimeout(() => {
checkAndEnableScrolling(trackTitleElement, trackTitle);
}, 100); // Allow DOM to settle
// Enable controls
document.getElementById('play-button').disabled = false;
document.getElementById('stop-button').disabled = false;
// Hide no track message
document.getElementById('no-track-message').classList.add('hidden');
// Auto-expand if collapsed
if (!mediaPlayerExpanded) {
toggleMediaPlayerExpansion();
}
}
function checkAndEnableScrolling(element, text) {
// Remove any existing scrolling class and reset styles
element.classList.remove('scrolling');
element.style.removeProperty('--scroll-distance');
// Force a layout to get accurate measurements
element.offsetWidth;
// Get the inner text element
const titleTextElement = element.querySelector('.title-text');
if (!titleTextElement) return;
// Check if text is wider than container
const containerWidth = element.offsetWidth;
const textWidth = titleTextElement.scrollWidth;
// Enable scrolling if text is significantly wider than container
if (textWidth > containerWidth + 15) {
const scrollDistance = containerWidth - textWidth;
element.style.setProperty('--scroll-distance', `${scrollDistance}px`);
element.classList.add('scrolling');
console.log(`📜 Enabled scrolling for title: "${text}"`);
console.log(`📜 Container: ${containerWidth}px, Text: ${textWidth}px, Scroll: ${scrollDistance}px`);
}
}
function clearTrack() {
// Force collapse the media player BEFORE clearing currentTrack
if (mediaPlayerExpanded) {
// Manually collapse since toggleMediaPlayerExpansion() needs currentTrack
mediaPlayerExpanded = false;
const mediaPlayer = document.getElementById('media-player');
const expandedContent = document.getElementById('media-expanded');
if (mediaPlayer) mediaPlayer.style.minHeight = '85px';
if (expandedContent) expandedContent.classList.add('hidden');
}
// Now clear track state
currentTrack = null;
isPlaying = false;
const trackTitleElement = document.getElementById('track-title');
trackTitleElement.innerHTML = '<span class="title-text">No track</span>';
trackTitleElement.classList.remove('scrolling'); // Remove scrolling animation
trackTitleElement.style.removeProperty('--scroll-distance'); // Clear CSS variable
document.getElementById('artist-name').textContent = 'Unknown Artist';
document.getElementById('album-name').textContent = 'Unknown Album';
document.getElementById('play-button').textContent = '▷';
document.getElementById('play-button').disabled = true;
document.getElementById('stop-button').disabled = true;
// Reset progress bar and time displays
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
if (progressBar) {
progressBar.value = 0;
delete progressBar.dataset.seeking;
}
if (progressFill) {
progressFill.style.width = '0%';
}
const currentTimeElement = document.getElementById('current-time');
const totalTimeElement = document.getElementById('total-time');
if (currentTimeElement) currentTimeElement.textContent = '0:00';
if (totalTimeElement) totalTimeElement.textContent = '0:00';
// Hide loading animation
hideLoadingAnimation();
// Show no track message
document.getElementById('no-track-message').classList.remove('hidden');
console.log('🧹 Track cleared and media player reset');
}
function setPlayingState(playing) {
isPlaying = playing;
const playButton = document.getElementById('play-button');
playButton.textContent = playing ? '⏸︎' : '▷';
}
async function handlePlayPause() {
// Use new streaming system toggle function
togglePlayback();
}
async function handleStop() {
// Use new streaming system stop function
await stopStream();
clearTrack();
}
function handleVolumeChange(event) {
const volume = event.target.value;
updateVolumeSliderAppearance();
// Update HTML5 audio player volume
if (audioPlayer) {
audioPlayer.volume = volume / 100;
}
}
function handleProgressBarChange(event) {
// Handle seeking in the audio track
if (!audioPlayer || !audioPlayer.duration) return;
const progress = parseFloat(event.target.value);
const newTime = (progress / 100) * audioPlayer.duration;
console.log(`🎯 Seeking to ${formatTime(newTime)} (${progress.toFixed(1)}%)`);
try {
audioPlayer.currentTime = newTime;
// Update visual progress immediately
const progressFill = document.getElementById('progress-fill');
if (progressFill) {
progressFill.style.width = `${progress}%`;
}
// Update time displays immediately
const currentTimeElement = document.getElementById('current-time');
if (currentTimeElement) {
currentTimeElement.textContent = formatTime(newTime);
}
} catch (error) {
console.warn('⚠️ Seek failed:', error.message);
// Reset progress bar to current position
const actualProgress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
event.target.value = actualProgress;
const progressFill = document.getElementById('progress-fill');
if (progressFill) {
progressFill.style.width = `${actualProgress}%`;
}
}
}
function updateVolumeSliderAppearance() {
const slider = document.getElementById('volume-slider');
const value = slider.value;
slider.style.setProperty('--volume-percent', `${value}%`);
}
function showLoadingAnimation() {
document.getElementById('loading-animation').classList.remove('hidden');
}
function hideLoadingAnimation() {
document.getElementById('loading-animation').classList.add('hidden');
}
function setLoadingProgress(percentage) {
const loadingAnimation = document.getElementById('loading-animation');
const progressBar = loadingAnimation.querySelector('.loading-progress');
const loadingText = loadingAnimation.querySelector('.loading-text');
loadingAnimation.classList.remove('hidden');
progressBar.style.width = `${percentage}%`;
loadingText.textContent = `${Math.round(percentage)}%`;
}
// ===============================
// STREAMING FUNCTIONALITY
// ===============================
async function startStream(searchResult) {
// Start streaming a track - handles same track toggle and new track streaming
try {
console.log(`🎮 startStream() called with data:`, searchResult);
// Check if this is the same track that's currently playing/loading
const currentTrackId = currentTrack ? `${currentTrack.username}:${currentTrack.filename}` : null;
const newTrackId = `${searchResult.username}:${searchResult.filename}`;
console.log(`🎮 startStream() called for: ${searchResult.filename}`);
console.log(`🎮 Current track ID: ${currentTrackId}`);
console.log(`🎮 New track ID: ${newTrackId}`);
if (currentTrackId === newTrackId && audioPlayer && !audioPlayer.paused) {
// Same track clicked while playing - toggle pause
console.log("🔄 Toggling playback for same track");
togglePlayback();
return;
}
// Different track or no current track - start new stream
console.log("🎵 Starting new stream");
// Stop current streaming/playback if any
await stopStream();
// Set track info and show loading state
setTrackInfo({
title: extractTrackTitle(searchResult.filename) || searchResult.title || 'Unknown Track',
artist: searchResult.artist || searchResult.username || 'Unknown Artist',
album: searchResult.album || 'Unknown Album',
username: searchResult.username,
filename: searchResult.filename
});
showLoadingAnimation();
setLoadingProgress(0);
// Start streaming request
const response = await fetch(API.stream.start, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(searchResult)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to start streaming');
}
console.log("✅ Stream started successfully");
// Start status polling
startStreamStatusPolling();
} catch (error) {
console.error('Error starting stream:', error);
showToast(`Failed to start stream: ${error.message}`, 'error');
hideLoadingAnimation();
clearTrack();
}
}
function startStreamStatusPolling() {
// Start polling for stream status updates with retry logic
if (streamStatusPoller) {
clearInterval(streamStatusPoller);
}
// Reset polling state
streamPollingRetries = 0;
streamPollingInterval = 1000; // Reset to 1-second interval
console.log('🔄 Starting enhanced stream status polling');
updateStreamStatus(); // Initial check
streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval);
}
function stopStreamStatusPolling() {
// Stop polling for stream status updates
if (streamStatusPoller) {
clearInterval(streamStatusPoller);
streamStatusPoller = null;
streamPollingRetries = 0;
streamPollingInterval = 1000; // Reset interval
console.log('⏹️ Stopped stream status polling');
}
}
async function updateStreamStatus() {
// Poll server for streaming progress and handle state changes with enhanced error recovery
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10-second timeout
const response = await fetch(API.stream.status, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Reset retry count on successful response
streamPollingRetries = 0;
streamPollingInterval = 1000; // Reset to normal interval
// Update current stream state
currentStream.status = data.status;
currentStream.progress = data.progress;
switch (data.status) {
case 'loading':
setLoadingProgress(data.progress);
// Update loading text with progress
const loadingText = document.querySelector('.loading-text');
if (loadingText && data.progress > 0) {
loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`;
}
break;
case 'queued':
// Show queue status with better messaging
const queueText = document.querySelector('.loading-text');
if (queueText) {
queueText.textContent = 'Queuing with uploader...';
}
setLoadingProgress(0); // Reset progress for queue state
break;
case 'ready':
// Stream is ready - start audio playback
console.log('🎵 Stream ready, starting audio playback');
stopStreamStatusPolling();
await startAudioPlayback();
break;
case 'error':
console.error('❌ Streaming error:', data.error_message);
stopStreamStatusPolling();
hideLoadingAnimation();
showToast(`Streaming error: ${data.error_message || 'Unknown error'}`, 'error');
clearTrack();
break;
case 'stopped':
// Handle stopped state
console.log('🛑 Stream stopped');
stopStreamStatusPolling();
hideLoadingAnimation();
clearTrack();
break;
}
} catch (error) {
streamPollingRetries++;
console.warn(`Stream status polling error (attempt ${streamPollingRetries}):`, error.message);
if (streamPollingRetries >= maxStreamPollingRetries) {
// Too many consecutive failures - give up
console.error('❌ Stream status polling failed after maximum retries');
stopStreamStatusPolling();
hideLoadingAnimation();
showToast('Lost connection to streaming server', 'error');
clearTrack();
} else {
// Implement exponential backoff for retries
const backoffMultiplier = Math.min(streamPollingRetries, 5); // Max 5x backoff
streamPollingInterval = 1000 * backoffMultiplier;
// Restart polling with new interval
if (streamStatusPoller) {
clearInterval(streamStatusPoller);
streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval);
console.log(`🔄 Retrying stream status polling with ${streamPollingInterval}ms interval`);
}
}
}
}
async function startAudioPlayback() {
// Start HTML5 audio playback of the streamed file with enhanced state management
try {
if (!audioPlayer) {
throw new Error('Audio player not initialized');
}
// Show loading state while preparing audio
const loadingText = document.querySelector('.loading-text');
if (loadingText) {
loadingText.textContent = 'Preparing playback...';
}
// Set audio source with cache-busting timestamp
const audioUrl = `/stream/audio?t=${new Date().getTime()}`;
console.log(`🎵 Loading audio from: ${audioUrl}`);
// Clear any existing source first
audioPlayer.pause();
audioPlayer.currentTime = 0;
audioPlayer.src = '';
// Set new source
audioPlayer.src = audioUrl;
audioPlayer.load(); // Force reload
// Wait for audio to be ready with promise-based approach
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Audio loading timeout'));
}, 15000); // 15-second timeout
const onCanPlay = () => {
clearTimeout(timeout);
audioPlayer.removeEventListener('canplay', onCanPlay);
audioPlayer.removeEventListener('error', onError);
resolve();
};
const onError = (event) => {
clearTimeout(timeout);
audioPlayer.removeEventListener('canplay', onCanPlay);
audioPlayer.removeEventListener('error', onError);
const error = event.target.error || new Error('Audio loading failed');
reject(error);
};
audioPlayer.addEventListener('canplay', onCanPlay);
audioPlayer.addEventListener('error', onError);
// If already ready, resolve immediately
if (audioPlayer.readyState >= 3) { // HAVE_FUTURE_DATA
onCanPlay();
}
});
console.log('✅ Audio loaded and ready for playback');
// Try to start playback with retry logic
let retryCount = 0;
const maxRetries = 3;
while (retryCount < maxRetries) {
try {
await audioPlayer.play();
console.log('✅ Audio playback started successfully');
// Update UI to playing state
hideLoadingAnimation();
setPlayingState(true);
// Show media player if hidden
const noTrackMessage = document.getElementById('no-track-message');
if (noTrackMessage) {
noTrackMessage.classList.add('hidden');
}
// Ensure media player is expanded when playback starts
if (!mediaPlayerExpanded) {
toggleMediaPlayerExpansion();
}
// Update volume to current slider value
const volumeSlider = document.getElementById('volume-slider');
if (volumeSlider) {
audioPlayer.volume = volumeSlider.value / 100;
}
// Enable play/stop buttons
const playButton = document.getElementById('play-button');
const stopButton = document.getElementById('stop-button');
if (playButton) playButton.disabled = false;
if (stopButton) stopButton.disabled = false;
return; // Success!
} catch (playError) {
retryCount++;
console.warn(`⚠️ Audio play attempt ${retryCount} failed:`, playError.message);
if (retryCount >= maxRetries) {
throw playError; // Re-throw after max retries
}
// Wait before retry with exponential backoff
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
}
}
} catch (error) {
console.error('❌ Error starting audio playback:', error);
hideLoadingAnimation();
// Provide user-friendly error messages
let userMessage = 'Playback failed';
if (error.message.includes('no supported source') ||
error.message.includes('Not supported') ||
error.message.includes('MEDIA_ELEMENT_ERROR')) {
userMessage = 'Audio format not supported by your browser. Try downloading instead.';
} else if (error.message.includes('network') || error.message.includes('fetch')) {
userMessage = 'Network error - please check your connection';
} else if (error.message.includes('decode')) {
userMessage = 'Audio file is corrupted or incompatible';
} else if (error.message.includes('timeout')) {
userMessage = 'Audio loading timeout - file may be too large';
} else if (error.message.includes('AbortError')) {
userMessage = 'Playback was interrupted';
}
showToast(userMessage, 'error');
clearTrack();
}
}
async function stopStream() {
// Stop streaming and clean up all state
try {
// Stop status polling
stopStreamStatusPolling();
// Stop audio playback
if (audioPlayer) {
audioPlayer.pause();
audioPlayer.src = '';
}
// Call backend stop endpoint
const response = await fetch(API.stream.stop, { method: 'POST' });
if (response.ok) {
const data = await response.json();
console.log('🛑 Stream stopped:', data.message);
}
// Reset UI state
hideLoadingAnimation();
setPlayingState(false);
// Reset stream state
currentStream = {
status: 'stopped',
progress: 0,
track: null
};
} catch (error) {
console.error('Error stopping stream:', error);
}
}
function togglePlayback() {
// Toggle play/pause for currently loaded audio
if (!audioPlayer || !currentTrack) {
console.log('⚠️ No audio player or track to toggle');
return;
}
if (audioPlayer.paused) {
audioPlayer.play()
.then(() => {
setPlayingState(true);
console.log('▶️ Resumed playback');
})
.catch(error => {
console.error('Error resuming playback:', error);
showToast('Failed to resume playback', 'error');
});
} else {
audioPlayer.pause();
setPlayingState(false);
console.log('⏸️ Paused playback');
}
}
// ===============================
// AUDIO EVENT HANDLERS
// ===============================
function updateAudioProgress() {
// Update progress bar based on audio playback time
if (!audioPlayer || !audioPlayer.duration) return;
const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
// Update progress bar
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
if (progressBar && !progressBar.dataset.seeking) {
progressBar.value = progress;
// Update visual progress fill
if (progressFill) {
progressFill.style.width = `${progress}%`;
}
}
// Update time display
const currentTimeElement = document.getElementById('current-time');
const totalTimeElement = document.getElementById('total-time');
if (currentTimeElement) {
currentTimeElement.textContent = formatTime(audioPlayer.currentTime);
}
if (totalTimeElement) {
totalTimeElement.textContent = formatTime(audioPlayer.duration);
}
}
function onAudioEnded() {
// Handle audio playback completion
console.log('🏁 Audio playback ended');
setPlayingState(false);
// Reset progress to beginning
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
if (progressBar) {
progressBar.value = 0;
}
if (progressFill) {
progressFill.style.width = '0%';
}
const currentTimeElement = document.getElementById('current-time');
if (currentTimeElement) {
currentTimeElement.textContent = '0:00';
}
// TODO: Auto-advance to next track if queue exists
}
function onAudioError(event) {
// Handle audio playback errors
const error = event.target.error;
console.error('❌ Audio error:', error);
// Don't show error toast if it's just a format/codec issue and retrying
if (error && error.code) {
console.error(`Audio error code: ${error.code}, message: ${error.message || 'Unknown error'}`);
// Only show user-facing errors for serious issues
if (error.code === 4) { // MEDIA_ELEMENT_ERROR: Media not supported
console.warn('⚠️ Media format not supported by browser, but streaming may still work');
// Don't clear track or show error - let retry logic handle it
return;
}
}
hideLoadingAnimation();
// Only clear track after a short delay to allow for recovery
setTimeout(() => {
if (audioPlayer && audioPlayer.error) {
let userMessage = 'Audio format not supported by your browser. Try downloading instead.';
if (error && error.code) {
switch (error.code) {
case 1: // MEDIA_ERR_ABORTED
userMessage = 'Playback was stopped';
break;
case 2: // MEDIA_ERR_NETWORK
userMessage = 'Network error - please try again';
break;
case 3: // MEDIA_ERR_DECODE
userMessage = 'Audio file is corrupted or incompatible';
break;
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
userMessage = 'Audio format not supported by your browser. Try downloading instead.';
break;
}
}
showToast(userMessage, 'error');
clearTrack();
}
}, 2000);
}
function onAudioLoadStart() {
// Handle audio load start
console.log('🔄 Audio loading started');
}
function onAudioCanPlay() {
// Handle when audio can start playing
console.log('✅ Audio ready to play');
}
function formatTime(seconds) {
// Format seconds as MM:SS
if (!seconds || !isFinite(seconds)) return '0:00';
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
// ===============================
// AUDIO FORMAT SUPPORT DETECTION
// ===============================
function getFileExtension(filename) {
if (!filename) return '';
const ext = filename.toLowerCase().match(/\.([^.]+)$/);
return ext ? ext[1] : '';
}
function isAudioFormatSupported(filename) {
const ext = getFileExtension(filename);
const supportedFormats = ['mp3', 'ogg', 'wav']; // Most reliable formats
const partialSupport = ['flac', 'aac']; // Depends on browser
const unsupported = ['m4a', 'wma', 'ape', 'aiff']; // Generally problematic
if (supportedFormats.includes(ext)) {
return true;
}
if (partialSupport.includes(ext)) {
// Test if browser can actually play this format
return canPlayAudioFormat(ext);
}
return false; // Unsupported formats
}
function canPlayAudioFormat(extension) {
const audio = document.createElement('audio');
const mimeTypes = {
'mp3': 'audio/mpeg',
'ogg': 'audio/ogg; codecs="vorbis"',
'wav': 'audio/wav',
'flac': 'audio/flac',
'aac': 'audio/aac',
'm4a': 'audio/mp4',
'wma': 'audio/x-ms-wma'
};
const mimeType = mimeTypes[extension];
if (!mimeType) return false;
const canPlay = audio.canPlayType(mimeType);
return canPlay === 'probably' || canPlay === 'maybe';
}
// ===============================
// DONATION WIDGET
// ===============================
function initializeDonationWidget() {
const toggleButton = document.getElementById('donation-toggle');
toggleButton.addEventListener('click', toggleDonationAddresses);
}
function toggleDonationAddresses() {
const addresses = document.getElementById('donation-addresses');
const toggleButton = document.getElementById('donation-toggle');
donationAddressesVisible = !donationAddressesVisible;
if (donationAddressesVisible) {
addresses.classList.remove('hidden');
toggleButton.textContent = 'Hide';
} else {
addresses.classList.add('hidden');
toggleButton.textContent = 'Show';
}
}
function openKofi() {
window.open('https://ko-fi.com/boulderbadgedad', '_blank');
console.log('Opening Ko-fi link');
}
async function copyAddress(address, cryptoName) {
try {
await navigator.clipboard.writeText(address);
showToast(`${cryptoName} address copied to clipboard`, 'success');
console.log(`Copied ${cryptoName} address: ${address}`);
} catch (error) {
console.error('Failed to copy address:', error);
showToast(`Failed to copy ${cryptoName} address`, 'error');
}
}
// ===============================
// SETTINGS FUNCTIONALITY
// ===============================
function initializeSettings() {
// This function is called when the settings page is loaded.
// It attaches event listeners to all interactive elements on the page.
// Main save button
const saveButton = document.getElementById('save-settings');
if (saveButton) {
saveButton.addEventListener('click', saveSettings);
}
// Server toggle buttons
const plexToggle = document.getElementById('plex-toggle');
if (plexToggle) {
plexToggle.addEventListener('click', () => toggleServer('plex'));
}
const jellyfinToggle = document.getElementById('jellyfin-toggle');
if (jellyfinToggle) {
jellyfinToggle.addEventListener('click', () => toggleServer('jellyfin'));
}
// Auto-detect buttons
const detectSlskdBtn = document.querySelector('#soulseek-url + .detect-button');
if (detectSlskdBtn) {
detectSlskdBtn.addEventListener('click', autoDetectSlskd);
}
const detectPlexBtn = document.querySelector('#plex-container .detect-button');
if (detectPlexBtn) {
detectPlexBtn.addEventListener('click', autoDetectPlex);
}
const detectJellyfinBtn = document.querySelector('#jellyfin-container .detect-button');
if (detectJellyfinBtn) {
detectJellyfinBtn.addEventListener('click', autoDetectJellyfin);
}
// Test connection buttons
// Test button event listeners removed - they use onclick attributes in HTML to avoid double firing
}
async function loadSettingsData() {
try {
const response = await fetch(API.settings);
const settings = await response.json();
// Populate Spotify settings
document.getElementById('spotify-client-id').value = settings.spotify?.client_id || '';
document.getElementById('spotify-client-secret').value = settings.spotify?.client_secret || '';
// Populate Tidal settings
document.getElementById('tidal-client-id').value = settings.tidal?.client_id || '';
document.getElementById('tidal-client-secret').value = settings.tidal?.client_secret || '';
// Populate Plex settings
document.getElementById('plex-url').value = settings.plex?.base_url || '';
document.getElementById('plex-token').value = settings.plex?.token || '';
// Populate Jellyfin settings
document.getElementById('jellyfin-url').value = settings.jellyfin?.base_url || '';
document.getElementById('jellyfin-api-key').value = settings.jellyfin?.api_key || '';
// Set active server and toggle visibility
const activeServer = settings.active_media_server || 'plex';
toggleServer(activeServer);
// Populate Soulseek settings
document.getElementById('soulseek-url').value = settings.soulseek?.slskd_url || '';
document.getElementById('soulseek-api-key').value = settings.soulseek?.api_key || '';
// Populate Download settings (right column)
document.getElementById('preferred-quality').value = settings.settings?.audio_quality || 'flac';
document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads';
document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer';
// Populate Database settings
document.getElementById('max-workers').value = settings.database?.max_workers || '5';
// Populate Metadata Enhancement settings
document.getElementById('metadata-enabled').checked = settings.metadata_enhancement?.enabled !== false;
document.getElementById('embed-album-art').checked = settings.metadata_enhancement?.embed_album_art !== false;
// Populate Playlist Sync settings
document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false;
// Populate Logging information (read-only)
document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO';
document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log';
} catch (error) {
console.error('Error loading settings:', error);
showToast('Failed to load settings', 'error');
}
}
function updateMediaServerFields() {
const serverType = document.getElementById('media-server-type').value;
const urlInput = document.getElementById('media-server-url');
const tokenInput = document.getElementById('media-server-token');
if (serverType === 'plex') {
urlInput.placeholder = 'http://localhost:32400';
tokenInput.placeholder = 'Plex Token';
} else {
urlInput.placeholder = 'http://localhost:8096';
tokenInput.placeholder = 'Jellyfin API Key';
}
}
function toggleServer(serverType) {
// Update toggle buttons
document.getElementById('plex-toggle').classList.remove('active');
document.getElementById('jellyfin-toggle').classList.remove('active');
document.getElementById(`${serverType}-toggle`).classList.add('active');
// Show/hide server containers
document.getElementById('plex-container').classList.toggle('hidden', serverType !== 'plex');
document.getElementById('jellyfin-container').classList.toggle('hidden', serverType !== 'jellyfin');
}
async function saveSettings() {
// Determine active server from toggle buttons
const activeServer = document.getElementById('plex-toggle').classList.contains('active') ? 'plex' : 'jellyfin';
const settings = {
active_media_server: activeServer,
spotify: {
client_id: document.getElementById('spotify-client-id').value,
client_secret: document.getElementById('spotify-client-secret').value
},
tidal: {
client_id: document.getElementById('tidal-client-id').value,
client_secret: document.getElementById('tidal-client-secret').value
},
plex: {
base_url: document.getElementById('plex-url').value,
token: document.getElementById('plex-token').value
},
jellyfin: {
base_url: document.getElementById('jellyfin-url').value,
api_key: document.getElementById('jellyfin-api-key').value
},
soulseek: {
slskd_url: document.getElementById('soulseek-url').value,
api_key: document.getElementById('soulseek-api-key').value,
download_path: document.getElementById('download-path').value,
transfer_path: document.getElementById('transfer-path').value
},
settings: {
audio_quality: document.getElementById('preferred-quality').value
},
database: {
max_workers: parseInt(document.getElementById('max-workers').value)
},
metadata_enhancement: {
enabled: document.getElementById('metadata-enabled').checked,
embed_album_art: document.getElementById('embed-album-art').checked
},
playlist_sync: {
create_backup: document.getElementById('create-backup').checked
}
};
try {
showLoadingOverlay('Saving settings...');
const response = await fetch(API.settings, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
const result = await response.json();
if (result.success) {
showToast('Settings saved successfully', 'success');
// Trigger immediate status update
setTimeout(updateServiceStatus, 1000);
} else {
showToast(`Failed to save settings: ${result.error}`, 'error');
}
} catch (error) {
console.error('Error saving settings:', error);
showToast('Failed to save settings', 'error');
} finally {
hideLoadingOverlay();
}
}
async function testConnection(service) {
try {
showLoadingOverlay(`Testing ${service} connection...`);
const response = await fetch(API.testConnection, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service })
});
const result = await response.json();
if (result.success) {
showToast(`${service} connection successful`, 'success');
} else {
showToast(`${service} connection failed: ${result.error}`, 'error');
}
} catch (error) {
console.error(`Error testing ${service} connection:`, error);
showToast(`Failed to test ${service} connection`, 'error');
} finally {
hideLoadingOverlay();
}
}
// Individual Auto-detect functions - same as GUI
async function autoDetectPlex() {
try {
showLoadingOverlay('Auto-detecting Plex server...');
const response = await fetch('/api/detect-media-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_type: 'plex' })
});
const result = await response.json();
if (result.success) {
document.getElementById('plex-url').value = result.found_url;
showToast(`Plex server detected: ${result.found_url}`, 'success');
} else {
showToast(result.error, 'error');
}
} catch (error) {
console.error('Error auto-detecting Plex:', error);
showToast('Failed to auto-detect Plex server', 'error');
} finally {
hideLoadingOverlay();
}
}
async function autoDetectJellyfin() {
try {
showLoadingOverlay('Auto-detecting Jellyfin server...');
const response = await fetch('/api/detect-media-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_type: 'jellyfin' })
});
const result = await response.json();
if (result.success) {
document.getElementById('jellyfin-url').value = result.found_url;
showToast(`Jellyfin server detected: ${result.found_url}`, 'success');
} else {
showToast(result.error, 'error');
}
} catch (error) {
console.error('Error auto-detecting Jellyfin:', error);
showToast('Failed to auto-detect Jellyfin server', 'error');
} finally {
hideLoadingOverlay();
}
}
async function autoDetectSlskd() {
try {
showLoadingOverlay('Auto-detecting Soulseek (slskd) server...');
const response = await fetch('/api/detect-soulseek', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (result.success) {
document.getElementById('soulseek-url').value = result.found_url;
showToast(`Soulseek server detected: ${result.found_url}`, 'success');
} else {
showToast(result.error, 'error');
}
} catch (error) {
console.error('Error auto-detecting Soulseek:', error);
showToast('Failed to auto-detect Soulseek server', 'error');
} finally {
hideLoadingOverlay();
}
}
function cancelDetection(service) {
const progressDiv = document.getElementById(`${service}-detection-progress`);
progressDiv.classList.add('hidden');
showToast(`${service} detection cancelled`, 'error');
}
function updateStatusDisplays() {
// Update status displays based on current service status
// This would be called after status updates
const services = ['spotify', 'media-server', 'soulseek'];
services.forEach(service => {
const display = document.getElementById(`${service}-status-display`);
if (display) {
// Status will be updated by the regular status monitoring
}
});
}
async function authenticateTidal() {
try {
showLoadingOverlay('Starting Tidal authentication...');
// This would trigger the OAuth flow
showToast('Tidal authentication started', 'success');
// In a real implementation, this would open the OAuth URL
window.open('/auth/tidal', '_blank');
} catch (error) {
console.error('Error authenticating Tidal:', error);
showToast('Failed to start Tidal authentication', 'error');
} finally {
hideLoadingOverlay();
}
}
function browsePath(pathType) {
showToast(`Path browser not available in web interface. Please enter path manually.`, 'error');
}
// ===============================
// SEARCH FUNCTIONALITY
// ===============================
function initializeSearch() {
// --- FIX: Corrected the element IDs to match the HTML ---
const searchInput = document.getElementById('downloads-search-input');
const searchButton = document.getElementById('downloads-search-btn');
// Add this line to get the cancel button
const cancelButton = document.getElementById('downloads-cancel-btn');
if (searchButton && searchInput) {
searchButton.addEventListener('click', performDownloadsSearch);
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') performDownloadsSearch();
});
}
// Add this event listener for the cancel button
if (cancelButton) {
cancelButton.addEventListener('click', () => {
if (searchAbortController) {
searchAbortController.abort(); // This cancels the fetch request
console.log("Search cancelled by user.");
}
});
}
}
async function performSearch() {
const query = document.getElementById('search-input').value.trim();
if (!query) {
showToast('Please enter a search term', 'error');
return;
}
try {
showLoadingOverlay('Searching...');
displaySearchResults([]); // Clear previous results
const response = await fetch(API.search, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
const data = await response.json();
if (data.error) {
showToast(`Search error: ${data.error}`, 'error');
return;
}
searchResults = data.results || [];
displaySearchResults(searchResults);
if (searchResults.length === 0) {
showToast('No results found', 'error');
} else {
showToast(`Found ${searchResults.length} results`, 'success');
}
} catch (error) {
console.error('Error performing search:', error);
showToast('Search failed', 'error');
} finally {
hideLoadingOverlay();
}
}
function displaySearchResults(results) {
const resultsContainer = document.getElementById('search-results');
if (!results.length) {
resultsContainer.innerHTML = '<div class="no-results">No search results</div>';
return;
}
resultsContainer.innerHTML = results.map((result, index) => {
const isAlbum = result.type === 'album';
const sizeText = isAlbum ?
`${result.track_count || 0} tracks, ${(result.size_mb || 0).toFixed(1)} MB` :
`${(result.file_size / 1024 / 1024).toFixed(1)} MB, ${result.bitrate || 0}kbps`;
return `
<div class="search-result-item" onclick="selectResult(${index})">
<div class="result-header">
<div class="result-info">
<div class="result-title">${escapeHtml(result.title)}</div>
<div class="result-artist">${escapeHtml(result.artist)}</div>
${result.album ? `<div class="result-album">${escapeHtml(result.album)}</div>` : ''}
</div>
<div class="result-actions">
<button class="stream-button" onclick="event.stopPropagation(); streamTrack(${index})">
▷ Stream
</button>
<button class="download-button" onclick="event.stopPropagation(); startDownload(${index})">
⬇ Download
</button>
</div>
</div>
<div class="result-details">
<span class="result-size">${sizeText}</span>
<span class="result-user">by ${escapeHtml(result.username)}</span>
${result.quality ? `<span class="result-quality">${escapeHtml(result.quality)}</span>` : ''}
</div>
</div>
`;
}).join('');
}
function selectResult(index) {
const result = searchResults[index];
if (!result) return;
console.log('Selected result:', result);
// Could show detailed view or additional actions here
}
async function startDownload(index) {
const result = searchResults[index];
if (!result) return;
try {
const response = await fetch('/api/downloads/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result)
});
const data = await response.json();
if (data.success) {
showToast('Download started', 'success');
} else {
showToast(`Download failed: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error starting download:', error);
showToast('Failed to start download', 'error');
}
}
// ===============================
// PAGE DATA LOADING
// ===============================
async function loadInitialData() {
try {
// Load artist bubble state first
await hydrateArtistBubblesFromSnapshot();
// Load dashboard data by default
await loadDashboardData();
} catch (error) {
console.error('Error loading initial data:', error);
}
}
async function loadDashboardData() {
try {
const response = await fetch(API.activity);
const data = await response.json();
const activityFeed = document.getElementById('activity-feed');
if (data.activities && data.activities.length) {
activityFeed.innerHTML = data.activities.map(activity => `
<div class="activity-item">
<span class="activity-time">${activity.time}</span>
<span class="activity-text">${escapeHtml(activity.text)}</span>
</div>
`).join('');
}
// Initialize wishlist count when dashboard loads
await updateWishlistCount();
// Start periodic refresh of wishlist count (every 30 seconds, matching GUI behavior)
stopWishlistCountPolling(); // Ensure no duplicates
wishlistCountInterval = setInterval(updateWishlistCount, 30000);
} catch (error) {
console.error('Error loading dashboard data:', error);
}
}
// ===========================================
// == SYNC PAGE SPOTIFY FUNCTIONALITY ==
// ===========================================
async function loadSyncData() {
// This is called when the sync page is navigated to.
if (!spotifyPlaylistsLoaded) {
await loadSpotifyPlaylists();
}
// Load YouTube playlists from backend (always refresh to get latest state)
await loadYouTubePlaylistsFromBackend();
}
async function checkForActiveProcesses() {
try {
const response = await fetch('/api/active-processes');
if (!response.ok) return;
const data = await response.json();
const processes = data.active_processes || [];
if (processes.length > 0) {
console.log(`🔄 Found ${processes.length} active process(es) from backend. Rehydrating UI...`);
// Separate download batch processes from YouTube playlist processes
const downloadProcesses = processes.filter(p => p.type === 'batch');
const youtubeProcesses = processes.filter(p => p.type === 'youtube_playlist');
console.log(`📊 Process breakdown: ${downloadProcesses.length} download batches, ${youtubeProcesses.length} YouTube playlists`);
// Rehydrate download modal processes (existing Spotify system)
for (const processInfo of downloadProcesses) {
if (!activeDownloadProcesses[processInfo.playlist_id]) {
rehydrateModal(processInfo);
}
}
// Note: YouTube playlists are handled by loadYouTubePlaylistsFromBackend() and rehydrateYouTubePlaylist()
// in loadSyncData(), which provides more complete data than active processes and handles download modal rehydration.
console.log(` Skipping ${youtubeProcesses.length} YouTube playlists - handled by full backend loading`);
}
} catch (error) {
console.error('Failed to check for active processes:', error);
}
}
async function rehydrateArtistAlbumModal(virtualPlaylistId, playlistName, batchId) {
/**
* Rehydrates an artist album download modal from backend process data.
* Extracts artist/album info from virtual playlist ID and recreates the modal.
*/
try {
console.log(`💧 Rehydrating artist album modal: ${virtualPlaylistId} (${playlistName})`);
// Extract artist_id and album_id from virtualPlaylistId format: artist_album_[artist_id]_[album_id]
const parts = virtualPlaylistId.split('_');
if (parts.length < 4 || parts[0] !== 'artist' || parts[1] !== 'album') {
console.error(`❌ Invalid virtual playlist ID format: ${virtualPlaylistId}`);
return;
}
const artistId = parts[2];
const albumId = parts.slice(3).join('_'); // Handle album IDs that might contain underscores
console.log(`🔍 Extracted from virtual playlist: artistId=${artistId}, albumId=${albumId}`);
// Fetch the album tracks to get proper artist and album data
try {
const response = await fetch(`/api/artist/${artistId}/album/${albumId}/tracks`);
const data = await response.json();
if (!data.success || !data.album || !data.tracks) {
console.error('❌ Failed to fetch album data for rehydration:', data.error);
return;
}
const album = data.album;
const tracks = data.tracks;
// Extract artist info from the first track (all tracks should have same artist)
const artist = {
id: artistId,
name: tracks[0].artists[0] // Use first artist name from first track
};
console.log(`✅ Retrieved album data: "${album.name}" by ${artist.name} (${tracks.length} tracks)`);
// Create the modal using the same function as normal artist album downloads
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, tracks, album, artist);
// Update the rehydrated process with batch info and hide modal for background rehydration
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
// Update button states to reflect running status
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Hide the modal - this is background rehydration, not user-requested
if (process.modalElement) {
process.modalElement.style.display = 'none';
console.log(`🔍 Hiding rehydrated modal for background processing: ${album.name}`);
}
console.log(`✅ Rehydrated artist album modal: ${artist.name} - ${album.name}`);
} else {
console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`);
}
} catch (error) {
console.error(`❌ Error fetching album data for rehydration:`, error);
}
} catch (error) {
console.error(`❌ Error rehydrating artist album modal:`, error);
}
}
async function rehydrateModal(processInfo, userRequested = false) {
const { playlist_id, playlist_name, batch_id } = processInfo;
console.log(`💧 Rehydrating modal for "${playlist_name}" (batch: ${batch_id}) - User requested: ${userRequested}`);
// Handle YouTube virtual playlists - skip rehydration here, handled by YouTube system
if (playlist_id.startsWith('youtube_')) {
console.log(`⏭️ Skipping YouTube virtual playlist rehydration - handled by YouTube system`);
return;
}
// Handle artist album virtual playlists
if (playlist_id.startsWith('artist_album_')) {
console.log(`💧 Rehydrating artist album virtual playlist: ${playlist_id}`);
await rehydrateArtistAlbumModal(playlist_id, playlist_name, batch_id);
return;
}
// Handle wishlist processes specially
if (playlist_id === "wishlist") {
console.log(`💧 [Rehydrate] Handling wishlist modal for active process: ${batch_id}`);
// Check if modal already exists and is visible
const existingProcess = activeDownloadProcesses[playlist_id];
const modalAlreadyOpen = existingProcess && existingProcess.modalElement &&
existingProcess.modalElement.style.display === 'flex';
if (modalAlreadyOpen) {
console.log(`💧 [Rehydrate] Wishlist modal already open - updating existing modal with auto-process state`);
// Update existing process with new batch info
existingProcess.status = 'running';
existingProcess.batchId = batch_id;
// Update UI to reflect running state
const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Ensure polling is active for live updates
if (!existingProcess.intervalId) {
console.log(`💧 [Rehydrate] Starting polling for existing modal`);
startModalDownloadPolling(playlist_id);
}
console.log(`✅ [Rehydrate] Successfully updated existing wishlist modal for auto-process`);
} else {
console.log(`💧 [Rehydrate] Creating new wishlist modal for active process: ${batch_id}`);
// Create the modal with current server state
await openDownloadMissingWishlistModal();
const process = activeDownloadProcesses[playlist_id];
if (!process) {
console.error('❌ [Rehydrate] Failed to create wishlist process in activeDownloadProcesses');
return;
}
// Sync process state with server
console.log(`✅ [Rehydrate] Syncing wishlist process state - batchId: ${batch_id}, status: running`);
process.status = 'running';
process.batchId = batch_id;
// Update UI to reflect running state
const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for live updates
startModalDownloadPolling(playlist_id);
// SIMPLIFIED VISIBILITY LOGIC: Show modal if user requested it, otherwise keep hidden for background sync
if (userRequested) {
console.log('👤 [Rehydrate] User requested - showing wishlist modal');
process.modalElement.style.display = 'flex';
WishlistModalState.setVisible();
WishlistModalState.clearUserClosed();
} else {
console.log('🔄 [Rehydrate] Background sync - keeping modal hidden until user interaction');
process.modalElement.style.display = 'none';
WishlistModalState.setHidden();
}
}
return;
}
// Handle regular Spotify playlist processes
let playlistData = spotifyPlaylists.find(p => p.id === playlist_id);
if (!playlistData) {
console.warn(`Cannot rehydrate modal: Playlist data for ${playlist_id} not loaded.`);
return;
}
await openDownloadMissingModal(playlist_id);
const process = activeDownloadProcesses[playlist_id];
if (!process) return;
process.status = 'running';
process.batchId = batch_id;
updatePlaylistCardUI(playlist_id);
updateRefreshButtonState();
document.getElementById(`begin-analysis-btn-${playlist_id}`).style.display = 'none';
document.getElementById(`cancel-all-btn-${playlist_id}`).style.display = 'inline-block';
startModalDownloadPolling(playlist_id);
process.modalElement.style.display = 'none';
}
// ===================================================================
// YOUTUBE PLAYLIST BACKEND HYDRATION FUNCTIONS
// ===================================================================
async function loadYouTubePlaylistsFromBackend() {
// Load all stored YouTube playlists from backend and recreate cards (similar to Spotify hydration)
try {
console.log('📋 Loading YouTube playlists from backend...');
const response = await fetch('/api/youtube/playlists');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch YouTube playlists');
}
const data = await response.json();
const playlists = data.playlists || [];
console.log(`🎬 Found ${playlists.length} stored YouTube playlists in backend`);
if (playlists.length === 0) {
console.log('📋 No YouTube playlists to hydrate');
return;
}
const container = document.getElementById('youtube-playlist-container');
// Create cards for playlists that don't already exist (avoid duplicates)
for (const playlistInfo of playlists) {
const urlHash = playlistInfo.url_hash;
// Check if card already exists (from rehydration or previous loading)
if (youtubePlaylistStates[urlHash] && youtubePlaylistStates[urlHash].cardElement &&
document.body.contains(youtubePlaylistStates[urlHash].cardElement)) {
console.log(`⏭️ Skipping existing YouTube playlist card: ${playlistInfo.playlist.name}`);
// Update existing state with backend data
const state = youtubePlaylistStates[urlHash];
state.phase = playlistInfo.phase;
state.discoveryProgress = playlistInfo.discovery_progress;
state.spotifyMatches = playlistInfo.spotify_matches;
state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id;
// Fetch discovery results for existing cards too if they don't have them
if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering' &&
(!state.discoveryResults || state.discoveryResults.length === 0)) {
try {
console.log(`🔍 Fetching missing discovery results for existing card: ${playlistInfo.playlist.name}`);
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
if (fullState.discovery_results) {
state.discoveryResults = fullState.discovery_results;
state.syncPlaylistId = fullState.sync_playlist_id;
state.syncProgress = fullState.sync_progress || {};
console.log(`✅ Restored ${state.discoveryResults.length} discovery results for existing card`);
}
}
} catch (error) {
console.warn(`⚠️ Error fetching discovery results for existing card:`, error.message);
}
}
continue;
}
console.log(`🎬 Creating YouTube playlist card: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase})`);
createYouTubeCardFromBackendState(playlistInfo);
// Fetch discovery results for non-fresh playlists (same logic as rehydrateYouTubePlaylist)
if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering') {
try {
console.log(`🔍 Fetching discovery results for: ${playlistInfo.playlist.name}`);
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
if (stateResponse.ok) {
const fullState = await stateResponse.json();
console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`);
// Store discovery results in local state
const state = youtubePlaylistStates[urlHash];
if (fullState.discovery_results && state) {
state.discoveryResults = fullState.discovery_results;
state.syncPlaylistId = fullState.sync_playlist_id;
state.syncProgress = fullState.sync_progress || {};
console.log(`✅ Restored ${state.discoveryResults.length} discovery results for: ${playlistInfo.playlist.name}`);
}
} else {
console.warn(`⚠️ Could not fetch discovery results for: ${playlistInfo.playlist.name}`);
}
} catch (error) {
console.warn(`⚠️ Error fetching discovery results for ${playlistInfo.playlist.name}:`, error.message);
}
}
}
// Rehydrate download modals for YouTube playlists in downloading/download_complete phases
for (const playlistInfo of playlists) {
if ((playlistInfo.phase === 'downloading' || playlistInfo.phase === 'download_complete') &&
playlistInfo.converted_spotify_playlist_id && playlistInfo.download_process_id) {
const convertedPlaylistId = playlistInfo.converted_spotify_playlist_id;
if (!activeDownloadProcesses[convertedPlaylistId]) {
console.log(`💧 Rehydrating download modal for YouTube playlist: ${playlistInfo.playlist.name}`);
try {
// Create the download modal using the YouTube-specific function
const spotifyTracks = youtubePlaylistStates[playlistInfo.url_hash]?.discoveryResults
?.filter(result => result.spotify_data)
?.map(result => result.spotify_data) || [];
if (spotifyTracks.length > 0) {
await openDownloadMissingModalForYouTube(
convertedPlaylistId,
playlistInfo.playlist.name,
spotifyTracks
);
// Set the modal to running state with the correct batch ID
const process = activeDownloadProcesses[convertedPlaylistId];
if (process) {
process.status = 'running';
process.batchId = playlistInfo.download_process_id;
// Update UI to running state
const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Start polling for this process
startModalDownloadPolling(convertedPlaylistId);
// Hide modal since this is background rehydration
process.modalElement.style.display = 'none';
console.log(`✅ Rehydrated download modal for YouTube playlist: ${playlistInfo.playlist.name}`);
}
} else {
console.warn(`⚠️ No Spotify tracks found for YouTube download modal: ${playlistInfo.playlist.name}`);
}
} catch (error) {
console.error(`❌ Error rehydrating download modal for ${playlistInfo.playlist.name}:`, error);
}
}
}
}
console.log(`✅ Successfully hydrated ${playlists.length} YouTube playlists from backend`);
} catch (error) {
console.error('❌ Error loading YouTube playlists from backend:', error);
showToast(`Error loading YouTube playlists: ${error.message}`, 'error');
}
}
function createYouTubeCardFromBackendState(playlistInfo) {
// Create YouTube playlist card from backend state data
const urlHash = playlistInfo.url_hash;
const playlist = playlistInfo.playlist;
const phase = playlistInfo.phase;
const container = document.getElementById('youtube-playlist-container');
// Remove placeholder if it exists
const placeholder = container.querySelector('.youtube-playlist-placeholder');
if (placeholder) {
placeholder.remove();
}
// Create card HTML (using EXACT same structure as createYouTubeCard)
const cardHtml = `
<div class="youtube-playlist-card" id="youtube-card-${urlHash}" data-url="${playlistInfo.url}" onclick="handleYouTubeCardClick('${urlHash}')">
<div class="playlist-card-icon youtube-icon">▶</div>
<div class="playlist-card-content">
<div class="playlist-card-name">${escapeHtml(playlist.name)}</div>
<div class="playlist-card-info">
<span class="playlist-card-track-count">${playlist.tracks.length} tracks</span>
<span class="playlist-card-phase-text" style="color: ${getPhaseColor(phase)};">${getPhaseText(phase)}</span>
</div>
</div>
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}">
${playlistInfo.spotify_total} / ✓ ${playlistInfo.spotify_matches} / ✗ ${playlistInfo.spotify_total - playlistInfo.spotify_matches} / ${Math.round(getProgressWidth(playlistInfo))}%
</div>
<button class="playlist-card-action-btn">${getActionButtonText(phase)}</button>
</div>
`;
container.insertAdjacentHTML('beforeend', cardHtml);
// Store state for UI management (but backend remains source of truth)
youtubePlaylistStates[urlHash] = {
phase: phase,
url: playlistInfo.url,
playlist: playlist,
cardElement: document.getElementById(`youtube-card-${urlHash}`),
discoveryResults: [],
discoveryProgress: playlistInfo.discovery_progress,
spotifyMatches: playlistInfo.spotify_matches,
convertedSpotifyPlaylistId: playlistInfo.converted_spotify_playlist_id,
backendSynced: true // Flag to indicate this came from backend
};
console.log(`🃏 Created YouTube card from backend state: ${playlist.name} (${phase})`);
}
function getActionButtonText(phase) {
switch (phase) {
case 'fresh': return 'Discover';
case 'discovering': return 'View Progress';
case 'discovered': return 'View Results';
case 'syncing': return 'View Sync';
case 'sync_complete': return 'Download';
case 'downloading': return 'View Downloads';
case 'download_complete': return 'Complete';
default: return 'Open';
}
}
function getPhaseText(phase) {
switch (phase) {
case 'fresh': return 'Ready to discover';
case 'discovering': return 'Discovering...';
case 'discovered': return 'Discovery Complete';
case 'syncing': return 'Syncing...';
case 'sync_complete': return 'Sync Complete';
case 'downloading': return 'Downloading...';
case 'download_complete': return 'Download Complete';
default: return phase;
}
}
function getPhaseColor(phase) {
switch (phase) {
case 'fresh': return '#999';
case 'discovering': case 'syncing': case 'downloading': return '#ffa500';
case 'discovered': case 'sync_complete': case 'download_complete': return '#1db954';
default: return '#999';
}
}
function getProgressWidth(playlistInfo) {
if (playlistInfo.phase === 'fresh') return 0;
if (playlistInfo.spotify_total === 0) return 0;
return Math.round((playlistInfo.spotify_matches / playlistInfo.spotify_total) * 100);
}
async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) {
// Rehydrate a YouTube playlist's discovery modal state (similar to rehydrateModal)
const urlHash = playlistInfo.url_hash;
const playlistName = playlistInfo.playlist_name;
const phase = playlistInfo.phase;
console.log(`💧 Rehydrating YouTube playlist "${playlistName}" (Phase: ${phase}) - User requested: ${userRequested}`);
try {
// First, ensure the card exists (create from backend if needed)
if (!youtubePlaylistStates[urlHash] || !youtubePlaylistStates[urlHash].cardElement) {
console.log(`🃏 Creating missing YouTube card for rehydration: ${playlistName}`);
// Since playlistInfo from active processes doesn't have full playlist data,
// we need to fetch it from the backend first
try {
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
if (stateResponse.ok) {
const fullPlaylistState = await stateResponse.json();
createYouTubeCardFromBackendState(fullPlaylistState);
} else {
console.error(`❌ Could not fetch full playlist state for card creation: ${playlistName}`);
return; // Can't create card without playlist data
}
} catch (error) {
console.error(`❌ Error fetching playlist state for card creation: ${error.message}`);
return;
}
}
// Fetch full state from backend to get discovery results
let fullState = null;
if (phase !== 'fresh' && phase !== 'discovering') {
try {
console.log(`🔍 Fetching full backend state for: ${playlistName}`);
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
if (stateResponse.ok) {
fullState = await stateResponse.json();
console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`);
}
} catch (error) {
console.warn(`⚠️ Could not fetch full state for ${playlistName}:`, error.message);
}
}
// Update local state to match backend
const state = youtubePlaylistStates[urlHash];
state.phase = phase;
state.discoveryProgress = playlistInfo.discovery_progress;
state.spotifyMatches = playlistInfo.spotify_matches;
state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id;
// Restore discovery results if we have them
if (fullState && fullState.discovery_results) {
state.discoveryResults = fullState.discovery_results;
state.syncPlaylistId = fullState.sync_playlist_id;
state.syncProgress = fullState.sync_progress || {};
console.log(`✅ Restored ${state.discoveryResults.length} discovery results from backend`);
// Update modal if it already exists
const existingModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (existingModal && !existingModal.classList.contains('hidden')) {
console.log(`🔄 Refreshing existing modal with restored discovery results`);
refreshYouTubeDiscoveryModalTable(urlHash);
}
}
// Update card display
updateYouTubeCardPhase(urlHash, phase);
updateYouTubeCardProgress(urlHash, playlistInfo);
// Handle active discovery polling
if (phase === 'discovering') {
console.log(`🔍 Resuming discovery polling for: ${playlistName}`);
startYouTubeDiscoveryPolling(urlHash);
}
// Open modal if user requested
if (userRequested) {
switch (phase) {
case 'discovering':
case 'discovered':
case 'syncing':
case 'sync_complete':
openYouTubeDiscoveryModal(urlHash);
break;
case 'downloading':
case 'download_complete':
// Open download modal if we have the converted playlist ID
if (playlistInfo.converted_spotify_playlist_id) {
await openDownloadMissingModal(playlistInfo.converted_spotify_playlist_id);
}
break;
}
}
console.log(`✅ Successfully rehydrated YouTube playlist: ${playlistName}`);
} catch (error) {
console.error(`❌ Error rehydrating YouTube playlist "${playlistName}":`, error);
}
}
async function removeYouTubePlaylistFromBackend(event, urlHash) {
// Remove YouTube playlist from backend storage and update UI
event.stopPropagation(); // Prevent card click
const state = youtubePlaylistStates[urlHash];
if (!state) return;
const playlistName = state.playlist.name;
try {
console.log(`🗑️ Removing YouTube playlist from backend: ${playlistName}`);
const response = await fetch(`/api/youtube/delete/${urlHash}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete playlist');
}
// Remove card from UI
if (state.cardElement) {
state.cardElement.remove();
}
// Remove from client state
delete youtubePlaylistStates[urlHash];
// Stop any active polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Close discovery modal if open
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (modal) {
modal.remove();
}
// Show placeholder if no cards left
const container = document.getElementById('youtube-playlist-container');
const cards = container.querySelectorAll('.youtube-playlist-card');
if (cards.length === 0) {
container.innerHTML = '<div class="youtube-playlist-placeholder">No YouTube playlists added yet. Parse a YouTube playlist URL above to get started!</div>';
}
showToast(`Removed "${playlistName}" from backend storage`, 'success');
console.log(`✅ Successfully removed YouTube playlist: ${playlistName}`);
} catch (error) {
console.error(`❌ Error removing YouTube playlist "${playlistName}":`, error);
showToast(`Error removing playlist: ${error.message}`, 'error');
}
}
async function loadSpotifyPlaylists() {
const container = document.getElementById('spotify-playlist-container');
const refreshBtn = document.getElementById('spotify-refresh-btn');
container.innerHTML = `<div class="playlist-placeholder">🔄 Loading playlists...</div>`;
refreshBtn.disabled = true;
refreshBtn.textContent = '🔄 Loading...';
try {
const response = await fetch('/api/spotify/playlists');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch playlists');
}
spotifyPlaylists = await response.json();
renderSpotifyPlaylists();
spotifyPlaylistsLoaded = true;
await checkForActiveProcesses();
} catch (error) {
container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${error.message}</div>`;
showToast(`Error loading playlists: ${error.message}`, 'error');
} finally {
refreshBtn.disabled = false;
refreshBtn.textContent = '🔄 Refresh';
}
}
function renderSpotifyPlaylists() {
const container = document.getElementById('spotify-playlist-container');
if (spotifyPlaylists.length === 0) {
container.innerHTML = `<div class="playlist-placeholder">No Spotify playlists found.</div>`;
return;
}
container.innerHTML = spotifyPlaylists.map(p => {
let statusClass = 'status-never-synced';
if (p.sync_status.startsWith('Synced')) statusClass = 'status-synced';
if (p.sync_status === 'Needs Sync') statusClass = 'status-needs-sync';
// This HTML structure creates the interactive playlist cards
return `
<div class="playlist-card" data-playlist-id="${p.id}" onclick="togglePlaylistSelection(event)">
<div class="playlist-card-main">
<div class="playlist-card-content">
<div class="playlist-card-name">${escapeHtml(p.name)}</div>
<div class="playlist-card-info">
<span>${p.track_count} tracks</span> •
<span class="playlist-card-status ${statusClass}">${p.sync_status}</span>
</div>
<div class="sync-progress-indicator" id="progress-${p.id}"></div>
</div>
<div class="playlist-card-actions">
<button id="action-btn-${p.id}" onclick="openPlaylistDetailsModal(event, '${p.id}')">Sync / Download</button>
<button id="progress-btn-${p.id}" class="view-progress-btn hidden" onclick="handleViewProgressClick(event, '${p.id}')">
View Progress
</button>
</div>
</div>
</div>
`;
}).join('');
}
function handleViewProgressClick(event, playlistId) {
event.stopPropagation(); // Prevent the card selection from toggling
const process = activeDownloadProcesses[playlistId];
if (process && process.modalElement) {
// If a process is active, just show its modal
console.log(`Re-opening active download modal for playlist ${playlistId}`);
process.modalElement.style.display = 'flex';
}
}
function updatePlaylistCardUI(playlistId) {
const process = activeDownloadProcesses[playlistId];
const progressBtn = document.getElementById(`progress-btn-${playlistId}`);
const actionBtn = document.getElementById(`action-btn-${playlistId}`);
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
if (!progressBtn || !actionBtn) return;
if (process && process.status === 'running') {
// A process is running: show the progress button
progressBtn.classList.remove('hidden');
progressBtn.textContent = 'View Progress';
progressBtn.style.backgroundColor = ''; // Reset any custom styling
actionBtn.textContent = '📥 Downloading...';
actionBtn.disabled = true;
// Remove completion styling from card
if (card) card.classList.remove('download-complete');
} else if (process && process.status === 'complete') {
// Process completed: show "ready for review" indicator
progressBtn.classList.remove('hidden');
progressBtn.textContent = '📋 View Results';
progressBtn.style.backgroundColor = '#28a745'; // Green success color
progressBtn.style.color = 'white';
actionBtn.textContent = '✅ Ready for Review';
actionBtn.disabled = false; // Allow clicking to see results
// Add completion styling to card
if (card) card.classList.add('download-complete');
} else {
// No process or it's been cleaned up: normal state
progressBtn.classList.add('hidden');
progressBtn.style.backgroundColor = ''; // Reset styling
progressBtn.style.color = ''; // Reset styling
actionBtn.textContent = 'Sync / Download';
actionBtn.disabled = false;
// Remove completion styling from card
if (card) card.classList.remove('download-complete');
}
}
async function cleanupDownloadProcess(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
console.log(`🧹 Cleaning up download process for playlist ${playlistId}`);
// Stop any active polling first
if (process.poller) {
console.log(`🛑 Stopping individual polling for ${playlistId}`);
clearInterval(process.poller);
process.poller = null;
}
// Mark process as no longer running
if (process.status === 'running') {
process.status = 'complete';
}
// If the process has a batchId, tell the server to clean it up.
if (process.batchId) {
try {
console.log(`🚀 Sending cleanup request to server for batch: ${process.batchId}`);
await fetch('/api/playlists/cleanup_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ batch_id: process.batchId })
});
console.log(`✅ Server cleanup completed for batch: ${process.batchId}`);
} catch (error) {
console.warn(`⚠️ Failed to send cleanup request to server:`, error);
// Don't show toast for cleanup failures - they're not user-facing
}
}
// Remove modal from DOM
if (process.modalElement && process.modalElement.parentElement) {
process.modalElement.parentElement.removeChild(process.modalElement);
}
// Remove from client-side global state
delete activeDownloadProcesses[playlistId];
// Check if global polling should be stopped
checkAndCleanupGlobalPolling();
// Restore card UI (only for non-wishlist playlists)
if (playlistId !== 'wishlist') {
updatePlaylistCardUI(playlistId);
}
updateRefreshButtonState(); // Now safe since hasActiveOperations() excludes wishlist
}
function togglePlaylistSelection(event) {
const card = event.currentTarget;
const playlistId = card.dataset.playlistId;
// Don't toggle if clicking the button
if (event.target.tagName === 'BUTTON') return;
const isSelected = !card.classList.contains('selected');
card.classList.toggle('selected', isSelected);
if (isSelected) {
selectedPlaylists.add(playlistId);
} else {
selectedPlaylists.delete(playlistId);
}
updateSyncActionsUI();
}
function updateSyncActionsUI() {
// If sequential sync is running, let the manager handle UI updates
if (sequentialSyncManager && sequentialSyncManager.isRunning) {
sequentialSyncManager.updateUI();
return;
}
const selectionInfo = document.getElementById('selection-info');
const startSyncBtn = document.getElementById('start-sync-btn');
const count = selectedPlaylists.size;
if (count === 0) {
if (selectionInfo) selectionInfo.textContent = 'Select playlists to sync';
if (startSyncBtn) startSyncBtn.disabled = true;
} else {
if (selectionInfo) selectionInfo.textContent = `${count} playlist${count > 1 ? 's' : ''} selected`;
if (startSyncBtn) startSyncBtn.disabled = false;
}
}
async function openPlaylistDetailsModal(event, playlistId) {
event.stopPropagation();
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
if (!playlist) return;
showLoadingOverlay(`Loading playlist: ${playlist.name}...`);
try {
// --- CACHING LOGIC START ---
if (playlistTrackCache[playlistId]) {
console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`);
// Use the cached tracks instead of fetching
const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] };
showPlaylistDetailsModal(fullPlaylist);
} else {
console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`);
// Fetch from the server if not in cache
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
// Store the fetched tracks in the cache
playlistTrackCache[playlistId] = fullPlaylist.tracks;
console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`);
showPlaylistDetailsModal(fullPlaylist);
}
// --- CACHING LOGIC END ---
} catch (error) {
showToast(`Error: ${error.message}`, 'error');
} finally {
hideLoadingOverlay();
}
}
function showPlaylistDetailsModal(playlist) {
// Create modal if it doesn't exist
let modal = document.getElementById('playlist-details-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'playlist-details-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
}
// Check if there's a completed download missing tracks process for this playlist
const activeProcess = activeDownloadProcesses[playlist.id];
const hasCompletedProcess = activeProcess && activeProcess.status === 'complete';
modal.innerHTML = `
<div class="modal-container playlist-modal">
<div class="playlist-modal-header">
<div class="playlist-header-content">
<h2>${escapeHtml(playlist.name)}</h2>
<div class="playlist-quick-info">
<span class="playlist-track-count">${playlist.track_count} tracks</span>
<span class="playlist-owner">by ${escapeHtml(playlist.owner)}</span>
</div>
<!-- Sync status display (hidden by default, matches GUI) -->
<div class="playlist-modal-sync-status" id="modal-sync-status-${playlist.id}" style="display: none;">
<span class="sync-stat total-tracks">♪ <span id="modal-total-${playlist.id}">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat matched-tracks">✓ <span id="modal-matched-${playlist.id}">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat failed-tracks">✗ <span id="modal-failed-${playlist.id}">0</span></span>
<span class="sync-stat percentage">(<span id="modal-percentage-${playlist.id}">0</span>%)</span>
</div>
</div>
<span class="playlist-modal-close" onclick="closePlaylistDetailsModal()">&times;</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">${track.artists.join(', ')}</div>
</div>
<div class="playlist-track-duration">${formatDuration(track.duration_ms)}</div>
</div>
`).join('')}
</div>
</div>
</div>
<div class="playlist-modal-footer">
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closePlaylistDetailsModal()">Close</button>
<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="openDownloadMissingModal('${playlist.id}')">
${hasCompletedProcess
? '📊 View Download Results'
: '📥 Download Missing Tracks'}
</button>
<button class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSync('${playlist.id}')">Sync Playlist</button>
</div>
</div>
`;
modal.style.display = 'flex';
}
function closePlaylistDetailsModal() {
const modal = document.getElementById('playlist-details-modal');
if (modal) {
modal.style.display = 'none';
}
}
function formatDuration(ms) {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
// ===============================
// DOWNLOAD MISSING TRACKS MODAL
// ===============================
let activeAnalysisTaskId = null;
let currentPlaylistTracks = [];
let analysisResults = [];
let missingTracks = [];
// New variables for enhanced modal functionality
let currentDownloadBatchId = null;
let modalDownloadPoller = null;
let currentModalPlaylistId = null;
// PHASE 2: Local cancelled track management (GUI PARITY)
let cancelledTracks = new Set(); // Track cancelled track indices like GUI's cancelled_tracks
async function openDownloadMissingModal(playlistId) {
// **NEW**: Check if a process is already active for this playlist
if (activeDownloadProcesses[playlistId]) {
console.log(`Modal for ${playlistId} already exists. Showing it.`);
closePlaylistDetailsModal(); // Close playlist details modal even when reusing existing modal
const process = activeDownloadProcesses[playlistId];
if (process.modalElement) {
// Show helpful message if it's a completed process
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
}
return; // Don't create a new one
}
console.log(`📥 Opening Download Missing Tracks modal for playlist: ${playlistId}`);
closePlaylistDetailsModal();
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
if (!playlist) {
showToast('Could not find playlist data.', 'error');
return;
}
let tracks = playlistTrackCache[playlistId];
if (!tracks) {
try {
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
tracks = fullPlaylist.tracks;
playlistTrackCache[playlistId] = tracks;
} catch (error) {
showToast(`Failed to fetch tracks: ${error.message}`, 'error');
return;
}
}
currentPlaylistTracks = tracks;
currentModalPlaylistId = playlistId;
let modal = document.createElement('div');
modal.id = `download-missing-modal-${playlistId}`; // **NEW**: Unique ID
modal.className = 'download-missing-modal'; // **NEW**: Use class for styling
modal.style.display = 'none'; // Start hidden
document.body.appendChild(modal);
// **NEW**: Register the new process in our global state tracker
activeDownloadProcesses[playlistId] = {
status: 'idle', // idle, running, complete, cancelled
modalElement: modal,
poller: null,
batchId: null,
playlist: playlist,
tracks: tracks
};
modal.innerHTML = `
<div class="download-missing-modal-content">
<div class="download-missing-modal-header">
<h2 class="download-missing-modal-title">Download Missing Tracks - ${escapeHtml(playlist.name)}</h2>
<span class="download-missing-modal-close" onclick="closeDownloadMissingModal('${playlistId}')">&times;</span>
</div>
<div class="download-missing-modal-body">
<div class="download-dashboard-stats">
<div class="dashboard-stat stat-total">
<div class="dashboard-stat-number" id="stat-total-${playlistId}">${tracks.length}</div>
<div class="dashboard-stat-label">Total Tracks</div>
</div>
<div class="dashboard-stat stat-found">
<div class="dashboard-stat-number" id="stat-found-${playlistId}">-</div>
<div class="dashboard-stat-label">Found in Library</div>
</div>
<div class="dashboard-stat stat-missing">
<div class="dashboard-stat-number" id="stat-missing-${playlistId}">-</div>
<div class="dashboard-stat-label">Missing Tracks</div>
</div>
<div class="dashboard-stat stat-downloaded">
<div class="dashboard-stat-number" id="stat-downloaded-${playlistId}">0</div>
<div class="dashboard-stat-label">Downloaded</div>
</div>
</div>
<div class="download-progress-section">
<div class="progress-item">
<div class="progress-label">
🔍 Library Analysis
<span id="analysis-progress-text-${playlistId}">Ready to start</span>
</div>
<div class="progress-bar">
<div class="progress-fill analysis" id="analysis-progress-fill-${playlistId}"></div>
</div>
</div>
<div class="progress-item">
<div class="progress-label">
⏬ Downloads
<span id="download-progress-text-${playlistId}">Waiting for analysis</span>
</div>
<div class="progress-bar">
<div class="progress-fill download" id="download-progress-fill-${playlistId}"></div>
</div>
</div>
</div>
<div class="download-tracks-section">
<div class="download-tracks-header">
<h3 class="download-tracks-title">📋 Track Analysis & Download Status</h3>
</div>
<div class="download-tracks-table-container">
<table class="download-tracks-table">
<thead>
<tr>
<th>#</th>
<th>Track</th>
<th>Artist</th>
<th>Duration</th>
<th>Library Match</th>
<th>Download Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="download-tracks-tbody-${playlistId}">
${tracks.map((track, index) => `
<tr data-track-index="${index}">
<td class="track-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(track.artists.join(', '))}">${track.artists.join(', ')}</td>
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
<td class="track-match-status match-checking" id="match-${playlistId}-${index}">🔍 Pending</td>
<td class="track-download-status" id="download-${playlistId}-${index}">-</td>
<td class="track-actions" id="actions-${playlistId}-${index}">-</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
</div>
<div class="download-missing-modal-footer">
<div class="download-phase-controls">
<button class="download-control-btn primary" id="begin-analysis-btn-${playlistId}" onclick="startMissingTracksProcess('${playlistId}')">
Begin Analysis
</button>
<button class="download-control-btn danger" id="cancel-all-btn-${playlistId}" onclick="cancelAllOperations('${playlistId}')" style="display: none;">
Cancel All
</button>
</div>
<div class="modal-close-section">
<button class="download-control-btn secondary" onclick="closeDownloadMissingModal('${playlistId}')">Close</button>
</div>
</div>
</div>
`;
modal.style.display = 'flex';
}
async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks) {
// 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 YouTube 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
};
// Use the exact same modal HTML structure as the existing Spotify modal
modal.innerHTML = `
<div class="download-missing-modal-content">
<div class="download-missing-modal-header">
<h2 class="download-missing-modal-title">Download Missing Tracks - ${escapeHtml(playlistName)}</h2>
<span class="download-missing-modal-close" onclick="closeDownloadMissingModal('${virtualPlaylistId}')">&times;</span>
</div>
<div class="download-missing-modal-body">
<div class="download-dashboard-stats">
<div class="dashboard-stat stat-total">
<div class="dashboard-stat-number" id="stat-total-${virtualPlaylistId}">${spotifyTracks.length}</div>
<div class="dashboard-stat-label">Total Tracks</div>
</div>
<div class="dashboard-stat stat-found">
<div class="dashboard-stat-number" id="stat-found-${virtualPlaylistId}">-</div>
<div class="dashboard-stat-label">Found in Library</div>
</div>
<div class="dashboard-stat stat-missing">
<div class="dashboard-stat-number" id="stat-missing-${virtualPlaylistId}">-</div>
<div class="dashboard-stat-label">Missing Tracks</div>
</div>
<div class="dashboard-stat stat-downloaded">
<div class="dashboard-stat-number" id="stat-downloaded-${virtualPlaylistId}">0</div>
<div class="dashboard-stat-label">Downloaded</div>
</div>
</div>
<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>
</div>
<div class="download-tracks-table-container">
<table class="download-tracks-table">
<thead>
<tr>
<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-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(track.artists.join(', '))}">${track.artists.join(', ')}</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">
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
Begin Analysis
</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>
`;
modal.style.display = 'flex';
}
async function closeDownloadMissingModal(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) {
// If somehow called without a process, try to find and remove the element
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
if (modal && modal.parentElement) {
modal.parentElement.removeChild(modal);
}
return;
}
// If the process is running, just hide the modal.
// If it's idle, complete, or cancelled, perform a full cleanup.
if (process.status === 'running') {
console.log(`Hiding active download modal for playlist ${playlistId}.`);
process.modalElement.style.display = 'none';
// Track wishlist modal state changes
if (playlistId === 'wishlist') {
WishlistModalState.setUserClosed(); // User manually closed during processing
console.log('📱 [Modal State] User manually closed wishlist modal during processing');
}
} else {
console.log(`Closing and cleaning up download modal for playlist ${playlistId}.`);
// Reset YouTube playlist phase to 'discovered' when modal is closed after completion
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'discovered');
// Update backend state to prevent rehydration issues on page refresh (similar to Tidal fix)
try {
const response = await fetch(`/api/youtube/update_phase/${urlHash}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
phase: 'discovered'
})
});
if (response.ok) {
console.log(`✅ [Modal Close] Updated backend phase for YouTube playlist ${urlHash} to 'discovered'`);
} else {
console.warn(`⚠️ [Modal Close] Failed to update backend phase for YouTube playlist ${urlHash}`);
}
} catch (error) {
console.error(`❌ [Modal Close] Error updating backend phase for YouTube playlist ${urlHash}:`, error);
}
}
// Enhanced Tidal playlist state management (based on GUI sync.py patterns)
if (playlistId.startsWith('tidal_')) {
const tidalPlaylistId = playlistId.replace('tidal_', '');
console.log(`🧹 [Modal Close] Processing Tidal playlist close: playlistId="${playlistId}", tidalPlaylistId="${tidalPlaylistId}"`);
console.log(`🧹 [Modal Close] Current Tidal state:`, tidalPlaylistStates[tidalPlaylistId]);
// Clear download-specific state but preserve discovery results (like GUI closeEvent)
if (tidalPlaylistStates[tidalPlaylistId]) {
const currentPhase = tidalPlaylistStates[tidalPlaylistId].phase;
console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`);
// Preserve discovery data for future use (like GUI modal behavior)
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-specific 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';
console.log(`🧹 [Modal Close] Reset Tidal playlist ${tidalPlaylistId} - cleared download state, preserved discovery data`);
console.log(`🧹 [Modal Close] New phase after reset: ${tidalPlaylistStates[tidalPlaylistId].phase}`);
} else {
console.error(`❌ [Modal Close] No Tidal state found for playlistId: ${tidalPlaylistId}`);
}
updateTidalCardPhase(tidalPlaylistId, 'discovered');
console.log(`🔄 [Modal Close] Reset Tidal playlist ${tidalPlaylistId} to discovered phase`);
console.log(`📝 [Modal Close] Expected button text for discovered phase: "${getActionButtonText('discovered')}"`);
// Update backend state to prevent rehydration issues on page refresh
try {
const response = await fetch(`/api/tidal/update_phase/${tidalPlaylistId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
phase: 'discovered'
})
});
if (response.ok) {
console.log(`✅ [Modal Close] Updated backend phase for Tidal playlist ${tidalPlaylistId} to 'discovered'`);
} else {
console.warn(`⚠️ [Modal Close] Failed to update backend phase for Tidal playlist ${tidalPlaylistId}`);
}
} catch (error) {
console.error(`❌ [Modal Close] Error updating backend phase for Tidal playlist ${tidalPlaylistId}:`, error);
}
}
// Clear wishlist modal state when modal is fully closed
if (playlistId === 'wishlist') {
WishlistModalState.clear(); // Clear all tracking since modal is fully closed
console.log('📱 [Modal State] Cleared wishlist modal state on full close');
}
// Clean up artist download if this is an artist album playlist
if (playlistId.startsWith('artist_album_')) {
console.log(`🧹 [MODAL CLOSE] Cleaning up artist download for completed modal: ${playlistId}`);
cleanupArtistDownload(playlistId);
console.log(`✅ [MODAL CLOSE] Artist download cleanup completed for: ${playlistId}`);
}
cleanupDownloadProcess(playlistId);
}
}
async function openDownloadMissingWishlistModal() {
const playlistId = "wishlist"; // Use a consistent ID for wishlist
// Check if a process is already active for the wishlist
if (activeDownloadProcesses[playlistId]) {
console.log(`Modal for wishlist already exists. Showing it.`);
const process = activeDownloadProcesses[playlistId];
if (process.modalElement) {
// Show helpful message if it's a completed process
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
WishlistModalState.setVisible(); // Track that modal is now visible
}
return; // Don't create a new one
}
console.log(`📥 Opening Download Missing Tracks modal for wishlist`);
// Fetch actual wishlist tracks from the server
let tracks;
try {
const response = await fetch('/api/wishlist/count');
const countData = await response.json();
if (countData.count === 0) {
showToast('Wishlist is empty. No tracks to download.', 'info');
return;
}
// Fetch the actual wishlist tracks for display
const tracksResponse = await fetch('/api/wishlist/tracks');
if (!tracksResponse.ok) {
throw new Error('Failed to fetch wishlist tracks');
}
const tracksData = await tracksResponse.json();
tracks = tracksData.tracks || [];
} catch (error) {
showToast(`Failed to fetch wishlist data: ${error.message}`, 'error');
return;
}
currentPlaylistTracks = tracks;
currentModalPlaylistId = playlistId;
let modal = document.createElement('div');
modal.id = `download-missing-modal-${playlistId}`; // Unique ID
modal.className = 'download-missing-modal'; // Use class for styling
modal.style.display = 'none'; // Start hidden
document.body.appendChild(modal);
// Register the new process in our global state tracker
activeDownloadProcesses[playlistId] = {
status: 'idle', // idle, running, complete, cancelled
modalElement: modal,
poller: null,
batchId: null,
playlist: { id: playlistId, name: "Wishlist" }, // Create a pseudo-playlist object
tracks: tracks
};
modal.innerHTML = `
<div class="download-missing-modal-content">
<div class="download-missing-modal-header">
<h2 class="download-missing-modal-title">Download Missing Tracks - Wishlist</h2>
<span class="download-missing-modal-close" onclick="closeDownloadMissingModal('${playlistId}')">&times;</span>
</div>
<div class="download-missing-modal-body">
<div class="download-dashboard-stats">
<div class="dashboard-stat stat-total">
<div class="dashboard-stat-number" id="stat-total-${playlistId}">${tracks.length}</div>
<div class="dashboard-stat-label">Total Tracks</div>
</div>
<div class="dashboard-stat stat-found">
<div class="dashboard-stat-number" id="stat-found-${playlistId}">-</div>
<div class="dashboard-stat-label">Found in Library</div>
</div>
<div class="dashboard-stat stat-missing">
<div class="dashboard-stat-number" id="stat-missing-${playlistId}">-</div>
<div class="dashboard-stat-label">Missing Tracks</div>
</div>
<div class="dashboard-stat stat-downloaded">
<div class="dashboard-stat-number" id="stat-downloaded-${playlistId}">0</div>
<div class="dashboard-stat-label">Downloaded</div>
</div>
</div>
<div class="download-progress-section">
<div class="progress-item">
<div class="progress-label">
🔍 Library Analysis
<span id="analysis-progress-text-${playlistId}">Ready to start</span>
</div>
<div class="progress-bar">
<div class="progress-fill analysis" id="analysis-progress-fill-${playlistId}"></div>
</div>
</div>
<div class="progress-item">
<div class="progress-label">
⏬ Downloads
<span id="download-progress-text-${playlistId}">Waiting for analysis</span>
</div>
<div class="progress-bar">
<div class="progress-fill download" id="download-progress-fill-${playlistId}"></div>
</div>
</div>
</div>
<div class="download-tracks-section">
<div class="download-tracks-header">
<h3 class="download-tracks-title">📋 Track Analysis & Download Status</h3>
</div>
<div class="download-tracks-table-container">
<table class="download-tracks-table">
<thead>
<tr>
<th>#</th>
<th>Track</th>
<th>Artist</th>
<th>Library Match</th>
<th>Download Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="download-tracks-tbody-${playlistId}">
${tracks.map((track, index) => `
<tr data-track-index="${index}">
<td class="track-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${formatArtists(track.artists)}</td>
<td class="track-match-status match-checking" id="match-${playlistId}-${index}">🔍 Pending</td>
<td class="track-download-status" id="download-${playlistId}-${index}">-</td>
<td class="track-actions" id="actions-${playlistId}-${index}">-</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
</div>
<div class="download-missing-modal-footer">
<div class="download-phase-controls">
<button class="download-control-btn primary" id="begin-analysis-btn-${playlistId}" onclick="startWishlistMissingTracksProcess('${playlistId}')">
Begin Analysis
</button>
<button class="download-control-btn danger" id="cancel-all-btn-${playlistId}" onclick="cancelAllOperations('${playlistId}')" style="display: none;">
Cancel All
</button>
<button class="download-control-btn danger" id="clear-wishlist-btn-${playlistId}" onclick="clearWishlist('${playlistId}')" style="margin-left: 10px;">
🗑️ Clear Wishlist
</button>
</div>
<div class="modal-close-section">
<button class="download-control-btn secondary" onclick="closeDownloadMissingModal('${playlistId}')">Close</button>
</div>
</div>
</div>
`;
modal.style.display = 'flex';
WishlistModalState.setVisible(); // Track that new wishlist modal is now visible
}
async function startWishlistMissingTracksProcess(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
console.log(`🚀 Kicking off wishlist missing tracks process`);
try {
process.status = 'running';
// Note: Wishlist processes don't affect sync page refresh button state
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none';
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block';
const response = await fetch('/api/wishlist/download_missing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!data.success) {
// Special handling for rate limit
if (response.status === 429) {
throw new Error(`${data.error} Try closing some other download processes first.`);
}
throw new Error(data.error);
}
process.batchId = data.batch_id;
console.log(`✅ Wishlist process started successfully. Batch ID: ${data.batch_id}`);
// Start polling for updates
startModalDownloadPolling(playlistId);
} catch (error) {
console.error('Error starting wishlist missing tracks process:', error);
showToast(`Error: ${error.message}`, 'error');
// Reset UI state on error
process.status = 'idle';
// Note: Wishlist processes don't affect sync page refresh button state
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'inline-block';
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none';
}
}
async function startMissingTracksProcess(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
console.log(`🚀 Kicking off unified missing tracks process for playlist: ${playlistId}`);
try {
process.status = 'running';
updatePlaylistCardUI(playlistId);
updateRefreshButtonState();
// Update YouTube playlist phase to 'downloading' if this is a YouTube playlist
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'downloading');
}
// Update Tidal playlist phase to 'downloading' if this is a Tidal playlist
if (playlistId.startsWith('tidal_')) {
const tidalPlaylistId = playlistId.replace('tidal_', '');
if (tidalPlaylistStates[tidalPlaylistId]) {
tidalPlaylistStates[tidalPlaylistId].phase = 'downloading';
updateTidalCardPhase(tidalPlaylistId, 'downloading');
console.log(`🔄 Updated Tidal playlist ${tidalPlaylistId} to downloading phase`);
}
}
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none';
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block';
const response = await fetch(`/api/playlists/${playlistId}/start-missing-process`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tracks: process.tracks,
playlist_name: process.playlist.name
})
});
const data = await response.json();
if (!data.success) {
// Special handling for rate limit
if (response.status === 429) {
throw new Error(`${data.error} Try closing some other download processes first.`);
}
throw new Error(data.error);
}
process.batchId = data.batch_id;
startModalDownloadPolling(playlistId);
} catch (error) {
showToast(`Failed to start process: ${error.message}`, 'error');
process.status = 'cancelled';
cleanupDownloadProcess(playlistId);
}
}
function updateTrackAnalysisResults(playlistId, results) {
// Update match results for all rows (tracks are now pre-populated)
for (const result of results) {
const matchElement = document.getElementById(`match-${playlistId}-${result.track_index}`);
if (matchElement) {
matchElement.textContent = result.found ? '✅ Found' : '❌ Missing';
matchElement.className = `track-match-status ${result.found ? 'match-found' : 'match-missing'}`;
}
}
}
// ============================================================================
// GLOBAL BATCHED POLLING SYSTEM - Optimized for multiple concurrent modals
// ============================================================================
let globalDownloadStatusPoller = null;
let globalPollingFailureCount = 0; // Track consecutive failures for exponential backoff
let globalPollingBaseInterval = 2000; // Base polling interval in ms - MATCHES sync.py exactly
function startGlobalDownloadPolling() {
if (globalDownloadStatusPoller) {
console.debug('🔄 [Global Polling] Already running, skipping start');
return; // Prevent duplicate pollers
}
console.log('🔄 [Global Polling] Starting batched download status polling');
globalDownloadStatusPoller = setInterval(async () => {
// Get all active processes that need polling
const activeBatchIds = [];
const batchToPlaylistMap = {};
let hasOpenWishlistModal = false;
Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => {
if (process.batchId && process.status === 'running') {
activeBatchIds.push(process.batchId);
batchToPlaylistMap[process.batchId] = playlistId;
}
// Check if there's an open wishlist modal (visible and idle/waiting)
if (playlistId === 'wishlist' && process.modalElement &&
process.modalElement.style.display === 'flex' &&
(!process.batchId || process.status !== 'running')) {
hasOpenWishlistModal = true;
}
});
// Special handling for open wishlist modal - check for new auto-processing
if (hasOpenWishlistModal) {
try {
const response = await fetch('/api/active-processes');
if (response.ok) {
const data = await response.json();
const processes = data.active_processes || [];
const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist');
if (serverWishlistProcess) {
console.log('🔄 [Global Polling] Detected auto-processing for open wishlist modal - rehydrating');
await rehydrateModal(serverWishlistProcess, false); // false = not user-requested
}
}
} catch (error) {
console.debug('⚠️ [Global Polling] Failed to check for wishlist auto-processing:', error);
}
}
if (activeBatchIds.length === 0) {
console.debug('📊 [Global Polling] No active processes, continuing polling');
return;
}
try {
// Single batched API call for all active processes
const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&');
const response = await fetch(`/api/download_status/batch?${queryParams}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.debug(`📊 [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`);
// Process each batch's status data using existing logic
Object.entries(data.batches).forEach(([batchId, statusData]) => {
const playlistId = batchToPlaylistMap[batchId];
if (!playlistId || statusData.error) {
if (statusData.error) {
console.error(`❌ [Global Polling] Error for batch ${batchId}:`, statusData.error);
}
return;
}
// Use existing modal update logic - zero changes needed!
processModalStatusUpdate(playlistId, statusData);
});
// ENHANCED: Reset failure count on successful polling
globalPollingFailureCount = 0;
} catch (error) {
console.error('❌ [Global Polling] Batched request failed:', error);
// ENHANCED: Implement exponential backoff on failure
globalPollingFailureCount++;
if (globalPollingFailureCount >= 5) {
console.error(`🚨 [Global Polling] ${globalPollingFailureCount} consecutive failures, continuing with backoff`);
// Don't stop polling - just continue with exponential backoff
}
// Exponential backoff: increase interval temporarily
const backoffInterval = Math.min(globalPollingBaseInterval * Math.pow(2, globalPollingFailureCount - 1), 8000);
console.warn(`⚠️ [Global Polling] Failure ${globalPollingFailureCount}/5, backing off to ${backoffInterval}ms`);
// Temporarily adjust the polling interval
if (globalDownloadStatusPoller) {
clearInterval(globalDownloadStatusPoller);
globalDownloadStatusPoller = null;
// Restart with backoff interval
setTimeout(() => {
if (Object.keys(activeDownloadProcesses).length > 0) {
startGlobalDownloadPollingWithInterval(backoffInterval);
}
}, backoffInterval);
}
}
}, globalPollingBaseInterval); // Use base interval initially
}
function startGlobalDownloadPollingWithInterval(interval) {
if (globalDownloadStatusPoller) {
console.debug('🔄 [Global Polling] Already running, skipping start with interval');
return;
}
console.log(`🔄 [Global Polling] Starting with interval ${interval}ms`);
// Use the exact same logic as startGlobalDownloadPolling but with custom interval
globalDownloadStatusPoller = setInterval(async () => {
const activeBatchIds = [];
const batchToPlaylistMap = {};
let hasOpenWishlistModal = false;
Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => {
if (process.batchId && process.status === 'running') {
activeBatchIds.push(process.batchId);
batchToPlaylistMap[process.batchId] = playlistId;
}
// Check if there's an open wishlist modal (visible and idle/waiting)
if (playlistId === 'wishlist' && process.modalElement &&
process.modalElement.style.display === 'flex' &&
(!process.batchId || process.status !== 'running')) {
hasOpenWishlistModal = true;
}
});
// Special handling for open wishlist modal - check for new auto-processing
if (hasOpenWishlistModal) {
try {
const response = await fetch('/api/active-processes');
if (response.ok) {
const data = await response.json();
const processes = data.active_processes || [];
const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist');
if (serverWishlistProcess) {
console.log('🔄 [Global Polling] Detected auto-processing for open wishlist modal - rehydrating');
await rehydrateModal(serverWishlistProcess, false); // false = not user-requested
}
}
} catch (error) {
console.debug('⚠️ [Global Polling] Failed to check for wishlist auto-processing:', error);
}
}
if (activeBatchIds.length === 0) {
console.debug('📊 [Global Polling] No active processes, continuing polling');
return;
}
try {
const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&');
const response = await fetch(`/api/download_status/batch?${queryParams}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.debug(`📊 [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`);
Object.entries(data.batches).forEach(([batchId, statusData]) => {
const playlistId = batchToPlaylistMap[batchId];
if (!playlistId || statusData.error) {
if (statusData.error) {
console.error(`❌ [Global Polling] Error for batch ${batchId}:`, statusData.error);
}
return;
}
processModalStatusUpdate(playlistId, statusData);
});
// Success - reset to normal interval if we were backing off
globalPollingFailureCount = 0;
if (interval !== globalPollingBaseInterval) {
console.log('✅ [Global Polling] Recovered from backoff, returning to normal interval');
clearInterval(globalDownloadStatusPoller);
globalDownloadStatusPoller = null;
startGlobalDownloadPolling(); // Restart with normal interval
}
} catch (error) {
console.error('❌ [Global Polling] Request failed:', error);
globalPollingFailureCount++;
if (globalPollingFailureCount >= 5) {
console.error(`🚨 [Global Polling] Too many failures, continuing with backoff`);
// Don't stop polling - just continue with exponential backoff
}
}
}, interval);
}
function stopGlobalDownloadPolling() {
if (globalDownloadStatusPoller) {
console.log('🛑 [Global Polling] Stopping batched download status polling');
clearInterval(globalDownloadStatusPoller);
globalDownloadStatusPoller = null;
}
}
function processModalStatusUpdate(playlistId, data) {
// This function contains ALL the existing polling logic from startModalDownloadPolling
// Extracted so it can be called from both individual and batched polling
const process = activeDownloadProcesses[playlistId];
if (!process) {
console.debug(`⚠️ [Status Update] No process found for ${playlistId}, skipping update`);
return;
}
if (data.error) {
console.error(`❌ [Status Update] Error for ${playlistId}: ${data.error}`);
return;
}
// ENHANCED: Validate response data to prevent UI corruption
if (!data || typeof data !== 'object') {
console.error(`❌ [Status Update] Invalid data for ${playlistId}:`, data);
return;
}
// ENHANCED: Validate task data structure
if (data.tasks && !Array.isArray(data.tasks)) {
console.error(`❌ [Status Update] Invalid tasks data for ${playlistId} - not an array:`, data.tasks);
return;
}
console.debug(`📊 [Status Update] Processing update for ${playlistId}: phase=${data.phase}, tasks=${(data.tasks || []).length}`);
// Note: Wishlist modal visibility is now managed by handleWishlistButtonClick() only
// Auto-show logic has been simplified to prevent conflicts
if (data.phase === 'analysis') {
const progress = data.analysis_progress;
const percent = progress.total > 0 ? (progress.processed / progress.total) * 100 : 0;
document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = `${percent}%`;
document.getElementById(`analysis-progress-text-${playlistId}`).textContent =
`${progress.processed}/${progress.total} tracks analyzed`;
if (data.analysis_results) {
updateTrackAnalysisResults(playlistId, data.analysis_results);
// Update stats when we first get analysis results
const foundCount = data.analysis_results.filter(r => r.found).length;
const missingCount = data.analysis_results.filter(r => !r.found).length;
document.getElementById(`stat-found-${playlistId}`).textContent = foundCount;
document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount;
}
} else if (data.phase === 'downloading' || data.phase === 'complete' || data.phase === 'error') {
console.debug(`📊 [Status Update] Processing ${data.phase} phase for playlistId: ${playlistId}, tasks: ${(data.tasks || []).length}`);
if (document.getElementById(`analysis-progress-fill-${playlistId}`).style.width !== '100%') {
document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = '100%';
document.getElementById(`analysis-progress-text-${playlistId}`).textContent = 'Analysis complete!';
if(data.analysis_results) {
updateTrackAnalysisResults(playlistId, data.analysis_results);
const foundCount = data.analysis_results.filter(r => r.found).length;
const missingCount = data.analysis_results.filter(r => !r.found).length;
document.getElementById(`stat-found-${playlistId}`).textContent = foundCount;
document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount;
}
}
const missingTracks = (data.analysis_results || []).filter(r => !r.found);
const missingCount = missingTracks.length;
let completedCount = 0;
let failedOrCancelledCount = 0;
// Verify modal exists before processing tasks
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
if (!modal) {
console.error(`❌ [Status Update] Modal not found: download-missing-modal-${playlistId}`);
return;
}
(data.tasks || []).forEach(task => {
const row = document.querySelector(`#download-missing-modal-${playlistId} tr[data-track-index="${task.track_index}"]`);
if (!row) {
console.debug(`❌ [Status Update] Row not found for playlistId: ${playlistId}, track_index: ${task.track_index}`);
return;
}
// V2 SYSTEM: Check for persistent cancel state from backend
const isV2Task = task.playlist_id !== undefined; // V2 tasks have playlist_id
const cancelRequested = task.cancel_requested || false;
const uiState = task.ui_state || 'normal';
// Legacy protection for old system compatibility
if (row.dataset.locallyCancelled === 'true' && !isV2Task) {
failedOrCancelledCount++;
return; // Only skip for legacy system tasks
}
// Mark row with V2 system info
if (isV2Task) {
row.dataset.useV2System = 'true';
row.dataset.cancelRequested = cancelRequested.toString();
row.dataset.uiState = uiState;
}
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 = '';
// V2 SYSTEM: Handle UI state override for cancelling tasks
if (isV2Task && uiState === 'cancelling' && task.status !== 'cancelled') {
statusText = '🔄 Cancelling...';
} else {
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;
case 'completed': statusText = '✅ Completed'; completedCount++; break;
case 'failed': statusText = '❌ Failed'; failedOrCancelledCount++; break;
case 'cancelled': statusText = '🚫 Cancelled'; failedOrCancelledCount++; break;
default: statusText = `${task.status}`; break;
}
}
if(statusEl) {
statusEl.textContent = statusText;
console.debug(`✅ [Status Update] Updated track ${task.track_index} to: ${statusText}${isV2Task ? ' (V2)' : ''}`);
} else {
console.warn(`❌ [Status Update] Status element not found: download-${playlistId}-${task.track_index}`);
}
// V2 SYSTEM: Smart button management with persistent state awareness
if (actionsEl && !['completed', 'failed', 'cancelled', 'post_processing'].includes(task.status)) {
// Check if we're in a cancelling state
if (isV2Task && uiState === 'cancelling') {
actionsEl.innerHTML = '<span style="color: #666;">Cancelling...</span>';
} else {
// Create V2 cancel button for all active tasks
const onclickHandler = isV2Task ? 'cancelTrackDownloadV2' : 'cancelTrackDownload';
actionsEl.innerHTML = `<button class="cancel-track-btn" title="Cancel this download" onclick="${onclickHandler}('${playlistId}', ${task.track_index})">×</button>`;
}
} else if (actionsEl && ['completed', 'failed', 'cancelled', 'post_processing'].includes(task.status)) {
actionsEl.innerHTML = '-'; // No actions available for terminal or processing states
}
});
// ENHANCED: Validate worker counts from server data
const serverActiveWorkers = data.active_count || 0;
const maxWorkers = data.max_concurrent || 3;
// V2 SYSTEM: Simplified worker counting - backend is authoritative
// Count active tasks, excluding locally cancelled legacy tasks only
const clientActiveWorkers = (data.tasks || []).filter(task => {
const row = document.querySelector(`tr[data-track-index="${task.track_index}"]`);
const isLegacyCancelled = row && row.dataset.locallyCancelled === 'true' && !row.dataset.useV2System;
return ['searching', 'downloading', 'queued'].includes(task.status) && !isLegacyCancelled;
}).length;
// Log discrepancies for debugging
if (serverActiveWorkers !== clientActiveWorkers) {
console.warn(`🔍 [Worker Validation] ${playlistId}: server reports ${serverActiveWorkers} active, client sees ${clientActiveWorkers} active tasks`);
// If server reports 0 but client sees active tasks, this might indicate ghost workers were fixed
if (serverActiveWorkers === 0 && clientActiveWorkers > 0) {
console.warn(`🚨 [Worker Validation] Server reports 0 workers but client sees ${clientActiveWorkers} active tasks - potential UI desync`);
}
}
console.debug(`📊 [Worker Status] ${playlistId}: ${serverActiveWorkers}/${maxWorkers} active workers, ${clientActiveWorkers} client-side active tasks`);
const totalFinished = completedCount + failedOrCancelledCount;
const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 0;
document.getElementById(`download-progress-fill-${playlistId}`).style.width = `${progressPercent}%`;
document.getElementById(`download-progress-text-${playlistId}`).textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`;
document.getElementById(`stat-downloaded-${playlistId}`).textContent = completedCount;
// FIXED: Only trigger completion logic when backend actually reports batch as complete
// Don't assume completion based on task counts - let backend determine when truly complete
if (data.phase === 'complete' || data.phase === 'error') {
// Enhanced check for background auto-processing for wishlist
const isWishlist = (playlistId === 'wishlist');
const isModalHidden = (process.modalElement && process.modalElement.style.display === 'none');
const isAutoInitiated = data.auto_initiated || false; // Server indicates if batch was auto-started
const isBackgroundWishlist = isWishlist && (isModalHidden || isAutoInitiated);
// Note: Auto-show logic removed - wishlist modal visibility managed by user interaction only
if (data.phase === 'cancelled') {
process.status = 'cancelled';
// Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on cancel
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'discovered');
}
showToast(`Process cancelled for ${process.playlist.name}.`, 'info');
} else if (data.phase === 'error') {
process.status = 'complete'; // Treat as complete to allow cleanup
updatePlaylistCardUI(playlistId); // Update card to show ready for review
// Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on error
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'discovered');
}
showToast(`Process for ${process.playlist.name} failed!`, 'error');
} else {
process.status = 'complete';
updatePlaylistCardUI(playlistId); // Update card to show ready for review
// Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
if (playlistId.startsWith('youtube_')) {
const urlHash = playlistId.replace('youtube_', '');
updateYouTubeCardPhase(urlHash, 'download_complete');
}
// Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist
if (playlistId.startsWith('tidal_')) {
const tidalPlaylistId = playlistId.replace('tidal_', '');
if (tidalPlaylistStates[tidalPlaylistId]) {
tidalPlaylistStates[tidalPlaylistId].phase = 'download_complete';
// Store the download process ID for potential modal rehydration
tidalPlaylistStates[tidalPlaylistId].download_process_id = process.batchId;
updateTidalCardPhase(tidalPlaylistId, 'download_complete');
console.log(`✅ [Status Complete] Updated Tidal playlist ${tidalPlaylistId} to download_complete phase`);
}
}
// Handle background wishlist processing completion specially
if (isBackgroundWishlist) {
console.log(`🎉 Background wishlist processing complete: ${completedCount} downloaded, ${failedOrCancelledCount} failed`);
// Reset modal to idle state to prevent "complete" phase disruption
setTimeout(() => {
resetWishlistModalToIdleState();
// Server-side auto-processing will handle next cycle automatically
}, 500);
return; // Skip normal completion handling
}
// Show completion summary with wishlist stats (matching sync.py behavior)
let completionMessage = `Process complete for ${process.playlist.name}!`;
let messageType = 'success';
// Check for wishlist summary from backend (added when failed/cancelled tracks are processed)
if (data.wishlist_summary) {
const summary = data.wishlist_summary;
completionMessage = `Download process complete! Downloaded: ${completedCount}, Failed/Cancelled: ${failedOrCancelledCount}.`;
if (summary.tracks_added > 0) {
completionMessage += ` Added ${summary.tracks_added} failed track${summary.tracks_added !== 1 ? 's' : ''} to wishlist for automatic retry.`;
} else if (summary.total_failed > 0) {
completionMessage += ` ${summary.total_failed} track${summary.total_failed !== 1 ? 's' : ''} could not be added to wishlist.`;
messageType = 'warning';
}
}
showToast(completionMessage, messageType);
}
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none';
// Mark process as complete and trigger cleanup check
process.status = 'complete';
updatePlaylistCardUI(playlistId);
// Check if any other processes still need polling
checkAndCleanupGlobalPolling();
}
}
}
function checkAndCleanupGlobalPolling() {
// Check if any processes still need polling
const hasActivePolling = Object.values(activeDownloadProcesses)
.some(p => p.batchId && p.status === 'running');
if (!hasActivePolling) {
console.debug('🧹 [Cleanup] No more active processes, continuing polling');
// Keep polling active - no need to stop
}
}
// LEGACY FUNCTION: Keep for backward compatibility, but now uses global polling
function startModalDownloadPolling(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process || !process.batchId) return;
console.log(`🔄 [Legacy Polling] Starting polling for ${playlistId}, delegating to global poller`);
// Clear any existing individual poller (cleanup)
if (process.poller) {
clearInterval(process.poller);
process.poller = null;
}
// Mark process as running to be picked up by global poller
process.status = 'running';
// Start global polling if not already running
startGlobalDownloadPolling();
// Create dummy poller for backward compatibility with cleanup functions
ensureLegacyCompatibility(playlistId);
}
// For backward compatibility with cleanup functions that expect process.poller
// Creates a dummy poller that will be cleaned up by the existing cleanup logic
function createLegacyPoller(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
// Create a dummy interval that just checks if the process is still active
// This ensures existing cleanup logic that calls clearInterval(process.poller) works
process.poller = setInterval(() => {
// This dummy poller doesn't do anything - global poller handles updates
if (!activeDownloadProcesses[playlistId] || process.status === 'complete') {
clearInterval(process.poller);
process.poller = null;
return;
}
}, 5000); // Very infrequent check, just for cleanup compatibility
}
// Call this to create the legacy poller after starting global polling
function ensureLegacyCompatibility(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (process && !process.poller) {
createLegacyPoller(playlistId);
}
}
async function updateModalWithLiveDownloadProgress() {
try {
if (!currentDownloadBatchId) return;
// Fetch live download data from the downloads API
const response = await fetch('/api/downloads/status');
const downloadData = await response.json();
if (downloadData.error) return;
// Get all active and finished downloads
const allDownloads = {...(downloadData.active || {}), ...(downloadData.finished || {})};
// Update modal tracks that have active downloads
const modalRows = document.querySelectorAll('.download-missing-modal tr[data-track-index]');
for (const row of modalRows) {
const taskId = row.dataset.taskId;
if (!taskId) continue;
// Find corresponding download by checking if filename/title matches
const trackName = row.querySelector('.track-name')?.textContent?.trim();
if (!trackName) continue;
// Search for matching download
for (const [downloadId, downloadInfo] of Object.entries(allDownloads)) {
const downloadTitle = downloadInfo.filename ? downloadInfo.filename.split(/[\\/]/).pop() : '';
// Simple matching - could be improved with better logic
if (downloadTitle && trackName && (
downloadTitle.toLowerCase().includes(trackName.toLowerCase()) ||
trackName.toLowerCase().includes(downloadTitle.toLowerCase())
)) {
// Update the track with live download progress
const statusElement = row.querySelector('.track-download-status');
const progress = downloadInfo.percentComplete || 0;
const state = downloadInfo.state || '';
if (statusElement && state.includes('InProgress') && progress > 0) {
statusElement.textContent = `⏬ Downloading... ${Math.round(progress)}%`;
statusElement.className = 'track-download-status download-downloading';
} else if (statusElement && (state.includes('Completed') || state.includes('Succeeded'))) {
statusElement.textContent = '✅ Completed';
statusElement.className = 'track-download-status download-complete';
}
break; // Found a match, stop searching
}
}
}
} catch (error) {
// Silent fail - don't spam console during normal operation
}
}
async function cancelAllOperations(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
// Prevent multiple cancel all operations
if (process.cancellingAll) {
console.log(`⚠️ Cancel All already in progress for ${playlistId}`);
return;
}
process.cancellingAll = true;
console.log(`🚫 Cancel All clicked for playlist ${playlistId} - closing modal and cleaning up server`);
showToast('Cancelling all operations and closing modal...', 'info');
// Mark process as complete immediately so polling stops
process.status = 'complete';
// Stop any active polling
if (process.poller) {
clearInterval(process.poller);
process.poller = null;
}
// Tell server to stop starting new downloads and clean up the batch
if (process.batchId) {
try {
// Cancel the batch (stops new downloads from starting)
const cancelResponse = await fetch(`/api/playlists/${process.batchId}/cancel_batch`, {
method: 'POST'
});
if (cancelResponse.ok) {
const cancelData = await cancelResponse.json();
console.log(`✅ Server stopped new downloads for batch ${process.batchId}`);
}
} catch (error) {
console.warn('Error during server batch cancel:', error);
}
}
// Close the modal immediately - this will handle cleanup
closeDownloadMissingModal(playlistId);
showToast('Modal closed. Active downloads will finish in background.', 'success');
}
function resetToInitialState() {
// Reset UI
document.getElementById('begin-analysis-btn').style.display = 'inline-block';
document.getElementById('start-downloads-btn').style.display = 'none';
document.getElementById('cancel-all-btn').style.display = 'none';
// Reset progress bars
document.getElementById('analysis-progress-fill').style.width = '0%';
document.getElementById('download-progress-fill').style.width = '0%';
document.getElementById('analysis-progress-text').textContent = 'Ready to start';
document.getElementById('download-progress-text').textContent = 'Waiting for analysis';
// Reset stats
document.getElementById('stat-found').textContent = '-';
document.getElementById('stat-missing').textContent = '-';
document.getElementById('stat-downloaded').textContent = '0';
// Reset track table
const tbody = document.getElementById('download-tracks-tbody');
if (tbody) {
const rows = tbody.querySelectorAll('tr');
rows.forEach((row, index) => {
const matchElement = row.querySelector('.track-match-status');
const downloadElement = row.querySelector('.track-download-status');
const actionsElement = row.querySelector('.track-actions');
if (matchElement) {
matchElement.textContent = '🔍 Pending';
matchElement.className = 'track-match-status match-checking';
}
if (downloadElement) {
downloadElement.textContent = '-';
downloadElement.className = 'track-download-status';
}
if (actionsElement) {
actionsElement.textContent = '-';
}
});
}
// Reset state
activeAnalysisTaskId = null;
analysisResults = [];
missingTracks = [];
}
// ===============================
// NEW ATOMIC CANCEL SYSTEM V2
// ===============================
async function cancelTrackDownloadV2(playlistId, trackIndex) {
/**
* NEW ATOMIC CANCEL SYSTEM V2
*
* - No optimistic UI updates
* - Single API call handles everything atomically
* - Backend is single source of truth for all state
* - No race conditions or dual state management
*/
const process = activeDownloadProcesses[playlistId];
if (!process) {
console.warn(`❌ [Cancel V2] No process found for playlist: ${playlistId}`);
return;
}
const row = document.querySelector(`#download-missing-modal-${playlistId} tr[data-track-index="${trackIndex}"]`);
if (!row) {
console.warn(`❌ [Cancel V2] No row found for track index: ${trackIndex}`);
return;
}
// Check if already in cancelling state
const statusEl = document.getElementById(`download-${playlistId}-${trackIndex}`);
const currentStatus = statusEl ? statusEl.textContent : '';
if (currentStatus.includes('Cancelling') || currentStatus.includes('Cancelled')) {
console.log(`⚠️ [Cancel V2] Task already being cancelled or cancelled: ${currentStatus}`);
return;
}
console.log(`🎯 [Cancel V2] Starting atomic cancel: playlist=${playlistId}, track=${trackIndex}`);
// V2 SYSTEM: Set temporary UI state - will be confirmed by server
row.dataset.uiState = 'cancelling';
// Show loading state only - no optimistic "cancelled" state
if (statusEl) {
statusEl.textContent = '🔄 Cancelling...';
}
// Disable the cancel button to prevent double-clicks
const actionsEl = document.getElementById(`actions-${playlistId}-${trackIndex}`);
if (actionsEl) {
actionsEl.innerHTML = '<span style="color: #666;">Cancelling...</span>';
}
try {
const response = await fetch('/api/downloads/cancel_task_v2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_id: playlistId,
track_index: trackIndex
})
});
const data = await response.json();
if (data.success) {
console.log(`✅ [Cancel V2] Successfully cancelled: ${data.task_info.track_name}`);
showToast(`Cancelled "${data.task_info.track_name}" and added to wishlist.`, 'success');
// Let the status polling system update the UI with server truth
// No manual UI updates - backend is authoritative
} else {
console.error(`❌ [Cancel V2] Cancel failed: ${data.error}`);
showToast(`Cancel failed: ${data.error}`, 'error');
// Reset UI to previous state on failure
row.dataset.uiState = 'normal'; // Reset UI state
if (statusEl) {
statusEl.textContent = '❌ Cancel Failed';
}
if (actionsEl) {
actionsEl.innerHTML = `<button class="cancel-track-btn" title="Cancel this download" onclick="cancelTrackDownloadV2('${playlistId}', ${trackIndex})">×</button>`;
}
}
} catch (error) {
console.error(`❌ [Cancel V2] Network/API error:`, error);
showToast(`Cancel request failed: ${error.message}`, 'error');
// Reset UI on network error
row.dataset.uiState = 'normal'; // Reset UI state
if (statusEl) {
statusEl.textContent = '❌ Cancel Failed';
}
if (actionsEl) {
actionsEl.innerHTML = `<button class="cancel-track-btn" title="Cancel this download" onclick="cancelTrackDownloadV2('${playlistId}', ${trackIndex})">×</button>`;
}
}
}
// ===============================
// LEGACY CANCEL SYSTEM (OLD)
// ===============================
async function cancelTrackDownload(playlistId, trackIndex) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
const row = document.querySelector(`#download-missing-modal-${playlistId} tr[data-track-index="${trackIndex}"]`);
if (!row) return;
// Prevent double cancellation
if (row.dataset.locallyCancelled === 'true') {
return; // Already cancelled locally
}
const taskId = row.dataset.taskId;
if (!taskId) {
showToast('Task not started yet, cannot cancel.', 'warning');
return;
}
// UI update for immediate feedback - mark as cancelled FIRST to prevent race conditions
row.dataset.locallyCancelled = 'true';
document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '🚫 Cancelling...';
document.getElementById(`actions-${playlistId}-${trackIndex}`).innerHTML = '-';
try {
const response = await fetch('/api/downloads/cancel_task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId })
});
const data = await response.json();
if (data.success) {
// Update final UI state after successful cancellation
document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '🚫 Cancelled';
showToast('Download cancelled and added to wishlist.', 'info');
} else {
throw new Error(data.error);
}
} catch (error) {
// Reset UI state if cancellation failed
row.dataset.locallyCancelled = 'false';
document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '❌ Cancel Failed';
showToast(`Could not cancel task: ${error.message}`, 'error');
}
}
// Find and REPLACE the old startPlaylistSyncFromModal function
async function startPlaylistSync(playlistId) {
const startTime = Date.now();
console.log(`🚀 [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId}`);
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
if (!playlist) {
console.error(`❌ Could not find playlist data for ID: ${playlistId}`);
showToast('Could not find playlist data.', 'error');
return;
}
console.log(`✅ Found playlist: ${playlist.name} with ${playlist.track_count || 'unknown'} tracks`);
// Ensure we have the full track list before starting
let tracks = playlistTrackCache[playlistId];
if (!tracks) {
const trackFetchStart = Date.now();
console.log(`🔄 [${new Date().toTimeString().split(' ')[0]}] Cache miss - fetching tracks for playlist ${playlistId}`);
try {
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
tracks = fullPlaylist.tracks;
playlistTrackCache[playlistId] = tracks; // Cache it
const trackFetchTime = Date.now() - trackFetchStart;
console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Fetched and cached ${tracks.length} tracks (took ${trackFetchTime}ms)`);
} catch (error) {
console.error(`❌ Failed to fetch tracks:`, error);
showToast(`Failed to fetch tracks for sync: ${error.message}`, 'error');
return;
}
} else {
console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Using cached tracks: ${tracks.length} tracks`);
}
// DON'T close the modal - let it show live progress like the GUI
try {
const syncStartTime = Date.now();
console.log(`🔄 [${new Date().toTimeString().split(' ')[0]}] Making API call to /api/sync/start with ${tracks.length} tracks`);
const response = await fetch('/api/sync/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_id: playlist.id,
playlist_name: playlist.name,
tracks: tracks // Send the full track list
})
});
const syncRequestTime = Date.now() - syncStartTime;
console.log(`📡 [${new Date().toTimeString().split(' ')[0]}] API response status: ${response.status} (took ${syncRequestTime}ms)`);
const data = await response.json();
console.log(`📡 [${new Date().toTimeString().split(' ')[0]}] API response data:`, data);
if (!data.success) throw new Error(data.error);
const totalTime = Date.now() - startTime;
console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Sync started successfully for "${playlist.name}" (total time: ${totalTime}ms)`);
showToast(`Sync started for "${playlist.name}"`, 'success');
// Show initial sync state in modal if open
const modal = document.getElementById('playlist-details-modal');
if (modal && modal.style.display !== 'none') {
const statusDisplay = document.getElementById(`modal-sync-status-${playlist.id}`);
if (statusDisplay) {
statusDisplay.style.display = 'flex';
console.log(`📊 [${new Date().toTimeString().split(' ')[0]}] Showing modal sync status for ${playlist.id}`);
}
}
updateCardToSyncing(playlist.id, 0); // Initial state
startSyncPolling(playlist.id);
} catch (error) {
console.error(`❌ Failed to start sync:`, error);
showToast(`Failed to start sync: ${error.message}`, 'error');
updateCardToDefault(playlist.id);
}
}
// Add these new helper functions to script.js
function startSyncPolling(playlistId) {
// Clear any existing poller for this playlist
if (activeSyncPollers[playlistId]) {
clearInterval(activeSyncPollers[playlistId]);
}
// Start a new poller that checks every 2 seconds
console.log(`🔄 Starting sync polling for playlist: ${playlistId}`);
activeSyncPollers[playlistId] = setInterval(async () => {
try {
console.log(`📊 Polling sync status for: ${playlistId}`);
const response = await fetch(`/api/sync/status/${playlistId}`);
const state = await response.json();
console.log(`📊 Poll response:`, state);
if (state.status === 'syncing') {
const progress = state.progress;
console.log(`📊 Sync progress:`, progress);
console.log(` 📊 Progress values: ${progress.progress}% | Total: ${progress.total_tracks} | Matched: ${progress.matched_tracks} | Failed: ${progress.failed_tracks}`);
console.log(` 📊 Current step: "${progress.current_step}" | Current track: "${progress.current_track}"`);
// Use the actual progress percentage from the sync service
updateCardToSyncing(playlistId, progress.progress, progress);
// Also update the modal if it's open
updateModalSyncProgress(playlistId, progress);
} else if (state.status === 'finished' || state.status === 'error' || state.status === 'cancelled') {
console.log(`🏁 Sync completed with status: ${state.status}`);
stopSyncPolling(playlistId);
updateCardToDefault(playlistId, state);
// Also update the modal if it's open
closePlaylistDetailsModal(); // Close modal on completion/error
}
} catch (error) {
console.error(`❌ Error polling sync status for ${playlistId}:`, error);
stopSyncPolling(playlistId);
updateCardToDefault(playlistId, { status: 'error', error: 'Polling failed' });
}
}, 2000); // Poll every 2 seconds
updateRefreshButtonState();
}
function stopSyncPolling(playlistId) {
if (activeSyncPollers[playlistId]) {
clearInterval(activeSyncPollers[playlistId]);
delete activeSyncPollers[playlistId];
}
updateRefreshButtonState();
}
// Sequential Sync Functions
function startSequentialSync() {
// Initialize manager if needed
if (!sequentialSyncManager) {
sequentialSyncManager = new SequentialSyncManager();
}
// Check if already running - if so, cancel
if (sequentialSyncManager.isRunning) {
sequentialSyncManager.cancel();
return;
}
// Validate selection
if (selectedPlaylists.size === 0) {
showToast('No playlists selected for sync', 'error');
return;
}
// Get playlist order from DOM to maintain display order
const playlistCards = document.querySelectorAll('.playlist-card');
const orderedPlaylistIds = [];
playlistCards.forEach(card => {
const playlistId = card.dataset.playlistId;
if (selectedPlaylists.has(playlistId)) {
orderedPlaylistIds.push(playlistId);
}
});
console.log(`🚀 Starting sequential sync for ${orderedPlaylistIds.length} playlists`);
// Start sequential sync
sequentialSyncManager.start(orderedPlaylistIds);
// Disable playlist selection during sync
disablePlaylistSelection(true);
}
function disablePlaylistSelection(disabled) {
const checkboxes = document.querySelectorAll('.playlist-checkbox');
checkboxes.forEach(checkbox => {
checkbox.disabled = disabled;
});
}
function hasActiveOperations() {
const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0;
// Only check non-wishlist download processes for sync page refresh button
const hasActiveDownloads = Object.entries(activeDownloadProcesses)
.filter(([playlistId, process]) => playlistId !== 'wishlist') // Exclude wishlist
.some(([_, process]) => process.status === 'running');
const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning;
return hasActiveSyncs || hasActiveDownloads || hasSequentialSync;
}
function updateRefreshButtonState() {
const refreshBtn = document.getElementById('spotify-refresh-btn');
if (!refreshBtn) return;
if (hasActiveOperations()) {
refreshBtn.disabled = true;
// Provide context-specific text
const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0;
const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning;
if (hasActiveSyncs || hasSequentialSync) {
refreshBtn.textContent = '🔄 Syncing...';
} else {
refreshBtn.textContent = '📥 Downloading...';
}
} else {
refreshBtn.disabled = false;
refreshBtn.textContent = '🔄 Refresh';
}
}
function updateCardToSyncing(playlistId, percent, progress = null) {
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
if (!card) return;
const progressBar = card.querySelector('.sync-progress-indicator');
progressBar.style.display = 'block';
let progressText = 'Starting...';
let actualPercent = percent || 0;
if (progress) {
// Create detailed progress text like the GUI
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const currentStep = progress.current_step || 'Processing';
// Calculate actual progress as processed/total, not just successful/total
if (total > 0) {
const processed = matched + failed;
actualPercent = Math.round((processed / total) * 100);
progressText = `${currentStep}: ${processed}/${total} (${matched} matched, ${failed} failed)`;
} else {
progressText = currentStep;
}
// If there's a current track being processed, show it
if (progress.current_track) {
progressText += ` - ${progress.current_track}`;
}
}
// Build live status counter HTML (same as modal)
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>
`;
}
progressBar.innerHTML = `
${statusCounterHTML}
<div class="progress-bar-sync">
<div class="progress-fill-sync" style="width: ${actualPercent}%;"></div>
</div>
<div class="progress-text-sync">${progressText}</div>
`;
}
function updateCardToDefault(playlistId, finalState = null) {
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
if (!card) return;
const progressBar = card.querySelector('.sync-progress-indicator');
progressBar.style.display = 'none';
progressBar.innerHTML = '';
const statusEl = card.querySelector('.playlist-card-status');
if (finalState) {
if (finalState.status === 'finished') {
statusEl.textContent = `Synced: Just now`;
statusEl.className = 'playlist-card-status status-synced';
showToast(`Sync complete for "${card.querySelector('.playlist-card-name').textContent}"`, 'success');
} else {
statusEl.textContent = `Sync Failed`;
statusEl.className = 'playlist-card-status status-needs-sync'; // Or a new error class
showToast(`Sync failed: ${finalState.error || 'Unknown error'}`, 'error');
}
}
}
// Update the modal's sync progress display (matches GUI functionality)
function updateModalSyncProgress(playlistId, progress) {
const modal = document.getElementById('playlist-details-modal');
if (modal && modal.style.display !== 'none') {
console.log(`📊 Updating modal sync progress for ${playlistId}:`, progress);
// Show sync status display
const statusDisplay = document.getElementById(`modal-sync-status-${playlistId}`);
if (statusDisplay) {
statusDisplay.style.display = 'flex';
// Update counters (matching GUI exactly)
const totalEl = document.getElementById(`modal-total-${playlistId}`);
const matchedEl = document.getElementById(`modal-matched-${playlistId}`);
const failedEl = document.getElementById(`modal-failed-${playlistId}`);
const percentageEl = document.getElementById(`modal-percentage-${playlistId}`);
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 GUI
if (total > 0) {
const processed = matched + failed;
const percentage = Math.round((processed / total) * 100);
if (percentageEl) percentageEl.textContent = percentage;
}
console.log(`📊 Modal updated: ♪ ${total} / ✓ ${matched} / ✗ ${failed} (${Math.round((matched + failed) / total * 100)}%)`);
} else {
console.warn(`❌ Modal sync status display not found for ${playlistId}`);
}
} else {
console.log(`📊 Modal not open for ${playlistId}, skipping update`);
}
}
// Download tracking state management - matching GUI functionality
let activeDownloads = {};
let finishedDownloads = {};
let downloadStatusInterval = null;
let isDownloadPollingActive = false;
async function loadDownloadsData() {
// Downloads page loads search results dynamically
console.log('Downloads page loaded');
// Connect downloads search button
const searchButton = document.getElementById('downloads-search-btn');
const searchInput = document.getElementById('downloads-search-input');
const clearButton = document.querySelector('.controls-panel__clear-btn');
if (searchButton && searchInput) {
searchButton.addEventListener('click', performDownloadsSearch);
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') performDownloadsSearch();
});
}
if (clearButton) {
clearButton.addEventListener('click', clearFinishedDownloads);
}
// Start sophisticated polling system (1-second interval like GUI)
startDownloadPolling();
// Initialize tab management
initializeDownloadTabs();
}
function startDownloadPolling() {
if (isDownloadPollingActive) return;
console.log('Starting download status polling (1-second interval)');
isDownloadPollingActive = true;
// Initial call
updateDownloadQueues();
// Start 1-second polling (matching GUI's 1000ms timer)
downloadStatusInterval = setInterval(updateDownloadQueues, 1000);
}
function stopDownloadPolling() {
if (downloadStatusInterval) {
clearInterval(downloadStatusInterval);
downloadStatusInterval = null;
}
isDownloadPollingActive = false;
console.log('Stopped download status polling');
}
async function updateDownloadQueues() {
try {
const response = await fetch('/api/downloads/status');
const data = await response.json();
if (data.error) {
console.error("Error fetching download status:", data.error);
return;
}
const newActive = {};
const newFinished = {};
// Terminal states matching GUI logic
const terminalStates = ['Completed', 'Succeeded', 'Cancelled', 'Canceled', 'Failed', 'Errored'];
// Process transfers exactly like GUI
data.transfers.forEach(item => {
const isTerminal = terminalStates.some(state =>
item.state && item.state.includes(state)
);
if (isTerminal) {
newFinished[item.id] = item;
} else {
newActive[item.id] = item;
}
});
// Update global state
activeDownloads = newActive;
finishedDownloads = newFinished;
// Render both queues
renderQueue('active-queue', activeDownloads, true);
renderQueue('finished-queue', finishedDownloads, false);
// Update tab counts
updateTabCounts();
// Update stats in the side panel
updateDownloadStats();
} catch (error) {
// Only log errors occasionally to avoid console spam
if (Math.random() < 0.1) {
console.error("Failed to update download queues:", error);
}
}
}
function renderQueue(containerId, downloads, isActiveQueue) {
const container = document.getElementById(containerId);
if (!container) return;
const downloadIds = Object.keys(downloads);
if (downloadIds.length === 0) {
container.innerHTML = `<div class="download-queue__empty-message">${isActiveQueue ? 'No active downloads.' : 'No finished downloads.'}</div>`;
return;
}
let html = '';
for (const id of downloadIds) {
const item = downloads[id];
const title = item.filename ? item.filename.split(/[\\/]/).pop() : 'Unknown File';
const progress = item.percentComplete || 0;
const bytesTransferred = item.bytesTransferred || 0;
const totalBytes = item.size || 0;
const speed = item.averageSpeed || 0;
// Format file size
const formatSize = (bytes) => {
if (!bytes) return 'Unknown size';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
// Format speed
const formatSpeed = (bytesPerSecond) => {
if (!bytesPerSecond || bytesPerSecond <= 0) return '';
return `${formatSize(bytesPerSecond)}/s`;
};
let actionButtonHTML = '';
if (isActiveQueue) {
// Active items get progress bar and cancel button
actionButtonHTML = `
<div class="download-item__progress-container">
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%;"></div>
</div>
<div class="progress-text">
${item.state} - ${progress.toFixed(1)}%
${speed > 0 ? `${formatSpeed(speed)}` : ''}
${totalBytes > 0 ? `${formatSize(bytesTransferred)} / ${formatSize(totalBytes)}` : ''}
</div>
</div>
<button class="download-item__cancel-btn" onclick="cancelDownloadItem('${item.id}', '${item.username}')">✕ Cancel</button>
`;
} else {
// Finished items get status and open button
let statusClass = '';
if (item.state.includes('Cancelled')) statusClass = 'status--cancelled';
else if (item.state.includes('Failed') || item.state.includes('Errored')) statusClass = 'status--failed';
else if (item.state.includes('Completed') || item.state.includes('Succeeded')) statusClass = 'status--completed';
actionButtonHTML = `
<div class="download-item__status-container">
<span class="download-item__status-text ${statusClass}">${item.state}</span>
</div>
<button class="download-item__open-btn" title="Cannot open folder from web browser" disabled>📁 Open</button>
`;
}
html += `
<div class="download-item" data-id="${item.id}">
<div class="download-item__header">
<div class="download-item__title" title="${title}">${title}</div>
<div class="download-item__uploader" title="from ${item.username}">from ${item.username}</div>
</div>
<div class="download-item__content">
${actionButtonHTML}
</div>
</div>
`;
}
container.innerHTML = html;
}
function updateTabCounts() {
const activeCount = Object.keys(activeDownloads).length;
const finishedCount = Object.keys(finishedDownloads).length;
const activeTabBtn = document.querySelector('.tab-btn[data-tab="active-queue"]');
const finishedTabBtn = document.querySelector('.tab-btn[data-tab="finished-queue"]');
if (activeTabBtn) activeTabBtn.textContent = `Download Queue (${activeCount})`;
if (finishedTabBtn) finishedTabBtn.textContent = `Finished (${finishedCount})`;
}
function updateDownloadStats() {
const activeCount = Object.keys(activeDownloads).length;
const finishedCount = Object.keys(finishedDownloads).length;
const activeLabel = document.getElementById('active-downloads-label');
const finishedLabel = document.getElementById('finished-downloads-label');
if (activeLabel) activeLabel.textContent = `• Active Downloads: ${activeCount}`;
if (finishedLabel) finishedLabel.textContent = `• Finished Downloads: ${finishedCount}`;
}
function initializeDownloadTabs() {
const tabButtons = document.querySelectorAll('.tab-btn');
tabButtons.forEach(btn => {
btn.addEventListener('click', () => switchDownloadTab(btn));
});
}
function switchDownloadTab(button) {
const targetTabId = button.getAttribute('data-tab');
// Update buttons
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Update content panes
document.querySelectorAll('.download-queue').forEach(queue => queue.classList.remove('active'));
const targetQueue = document.getElementById(targetTabId);
if (targetQueue) targetQueue.classList.add('active');
}
async function cancelDownloadItem(downloadId, username) {
try {
const response = await fetch('/api/downloads/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ download_id: downloadId, username: username })
});
const result = await response.json();
if (result.success) {
showToast('Download cancelled', 'success');
} else {
showToast(`Failed to cancel: ${result.error}`, 'error');
}
} catch (error) {
console.error('Error cancelling download:', error);
showToast('Error sending cancel request', 'error');
}
}
async function clearFinishedDownloads() {
const finishedCount = Object.keys(finishedDownloads).length;
if (finishedCount === 0) {
showToast('No finished downloads to clear', 'error');
return;
}
try {
const response = await fetch('/api/downloads/clear-finished', {
method: 'POST'
});
const result = await response.json();
if (result.success) {
showToast('Finished downloads cleared', 'success');
} else {
showToast(`Failed to clear: ${result.error}`, 'error');
}
} catch (error) {
console.error('Error clearing finished downloads:', error);
showToast('Error sending clear request', 'error');
}
}
// REPLACE the old performDownloadsSearch function with this new one.
async function performDownloadsSearch() {
const query = document.getElementById('downloads-search-input').value.trim();
if (!query) {
showToast('Please enter a search term', 'error');
return;
}
// --- UI Element References ---
const searchInput = document.getElementById('downloads-search-input');
const searchButton = document.getElementById('downloads-search-btn');
const cancelButton = document.getElementById('downloads-cancel-btn');
const statusText = document.getElementById('search-status-text');
const spinner = document.querySelector('.spinner-animation');
const dots = document.querySelector('.dots-animation');
// --- Start a new AbortController for this search ---
searchAbortController = new AbortController();
try {
// --- 1. Update UI to "Searching" State ---
searchInput.disabled = true;
searchButton.disabled = true;
cancelButton.classList.remove('hidden');
spinner.classList.remove('hidden');
dots.classList.remove('hidden');
statusText.textContent = `Searching for '${query}'...`;
displayDownloadsResults([]); // Clear previous results
// --- 2. Perform the Fetch Request ---
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
signal: searchAbortController.signal // Link fetch to the AbortController
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const results = data.results || [];
allSearchResults = results;
resetFilters();
applyFiltersAndSort();
// --- 3. Update UI with Success State ---
if (results.length === 0) {
statusText.textContent = `No results found for '${query}'`;
showToast('No results found', 'error');
} else {
document.getElementById('filters-container').classList.remove('hidden');
// Count albums and singles like the GUI app
let totalAlbums = 0;
let totalTracks = 0;
results.forEach(result => {
if (result.result_type === 'album') {
totalAlbums++;
} else {
totalTracks++;
}
});
statusText.textContent = `✨ Found ${results.length} results • ${totalAlbums} albums, ${totalTracks} singles`;
showToast(`Found ${results.length} results`, 'success');
}
} catch (error) {
// --- 4. Handle Errors, Including Cancellation ---
if (error.name === 'AbortError') {
// This specific error is thrown when the user clicks "Cancel"
statusText.textContent = 'Search was cancelled.';
showToast('Search cancelled', 'info');
displayDownloadsResults([]); // Clear any partial results
} else {
console.error('Search failed:', error);
statusText.textContent = `Search failed: ${error.message}`;
showToast('Search failed', 'error');
}
} finally {
// --- 5. Clean Up UI Regardless of Outcome ---
searchInput.disabled = false;
searchButton.disabled = false;
cancelButton.classList.add('hidden');
spinner.classList.add('hidden');
dots.classList.add('hidden');
searchAbortController = null; // Clear the controller
}
}
function displayDownloadsResults(results) {
const resultsArea = document.getElementById('search-results-area');
if (!resultsArea) return;
if (!results.length) {
resultsArea.innerHTML = '<div class="search-results-placeholder"><p>No search results found.</p></div>';
return;
}
let html = '';
results.forEach((result, index) => {
const isAlbum = result.result_type === 'album';
if (isAlbum) {
const trackCount = result.tracks ? result.tracks.length : 0;
const totalSize = result.total_size ? `${(result.total_size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
// Generate individual track items
let trackListHtml = '';
if (result.tracks && result.tracks.length > 0) {
result.tracks.forEach((track, trackIndex) => {
const trackSize = track.size ? `${(track.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
const trackBitrate = track.bitrate ? `${track.bitrate}kbps` : '';
trackListHtml += `
<div class="track-item">
<div class="track-item-info">
<div class="track-item-title">${escapeHtml(track.title || `Track ${trackIndex + 1}`)}</div>
<div class="track-item-details">
${track.track_number ? `${track.track_number}. ` : ''}${escapeHtml(track.artist || result.artist || 'Unknown Artist')}${trackSize}${escapeHtml(track.quality || 'Unknown')} ${trackBitrate}
</div>
</div>
<div class="track-item-actions">
<button onclick="streamAlbumTrack(${index}, ${trackIndex})" class="track-stream-btn">Stream ▶</button>
<button onclick="downloadAlbumTrack(${index}, ${trackIndex})" class="track-download-btn">Download ⬇</button>
<button onclick="matchedDownloadAlbumTrack(${index}, ${trackIndex})" class="track-matched-btn" title="Matched Download">Matched Download 🎯</button>
</div>
</div>
`;
});
}
html += `
<div class="album-result-card" data-album-index="${index}">
<div class="album-card-header" onclick="toggleAlbumExpansion(${index})">
<div class="album-expand-indicator">▶</div>
<div class="album-icon">💿</div>
<div class="album-info">
<div class="album-title">${escapeHtml(result.album_title || result.title || 'Unknown Album')}</div>
<div class="album-artist">by ${escapeHtml(result.artist || 'Unknown Artist')}</div>
<div class="album-details">
${trackCount} tracks • ${totalSize}${escapeHtml(result.quality || 'Mixed')}
</div>
<div class="album-uploader">Shared by ${escapeHtml(result.username || 'Unknown')}</div>
</div>
<div class="album-actions" onclick="event.stopPropagation()">
<button onclick="downloadAlbum(${index})" class="album-download-btn">⬇ Download Album</button>
<button onclick="matchedDownloadAlbum(${index})" class="album-matched-btn" title="Matched Album Download">Matched Album🎯</button>
</div>
</div>
<div class="album-track-list" style="display: none;">
${trackListHtml}
</div>
</div>
`;
} else {
const sizeText = result.size ? `${(result.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
const bitrateText = result.bitrate ? `${result.bitrate}kbps` : '';
html += `
<div class="track-result-card">
<div class="track-icon">🎵</div>
<div class="track-info">
<div class="track-title">${escapeHtml(result.title || 'Unknown Title')}</div>
<div class="track-artist">by ${escapeHtml(result.artist || 'Unknown Artist')}</div>
<div class="track-details">
${sizeText}${escapeHtml(result.quality || 'Unknown')} ${bitrateText}
</div>
<div class="track-uploader">Shared by ${escapeHtml(result.username || 'Unknown')}</div>
</div>
<div class="track-actions">
<button onclick="streamTrack(${index})" class="track-stream-btn" title="Stream Track">Stream ▶</button>
<button onclick="downloadTrack(${index})" class="track-download-btn" title="Download">Download ⬇</button>
<button onclick="matchedDownloadTrack(${index})" class="track-matched-btn" title="Matched Download">Matched Download🎯</button>
</div>
</div>
`;
}
});
resultsArea.innerHTML = html;
// Store results globally for download functions
window.currentSearchResults = results;
}
async function downloadTrack(index) {
const results = window.currentSearchResults;
if (!results || !results[index]) return;
const track = results[index];
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(track)
});
const data = await response.json();
if (data.success) {
showToast(`Download started: ${track.title}`, 'success');
} else {
showToast(`Download failed: ${data.error}`, 'error');
}
} catch (error) {
console.error('Download error:', error);
showToast('Failed to start download', 'error');
}
}
async function downloadAlbum(index) {
const results = window.currentSearchResults;
if (!results || !results[index]) return;
const album = results[index];
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(album)
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
} else {
showToast(`Album download failed: ${data.error}`, 'error');
}
} catch (error) {
console.error('Album download error:', error);
showToast('Failed to start album download', 'error');
}
}
// Matched download functions
function matchedDownloadTrack(index) {
const results = window.currentSearchResults;
if (!results || !results[index]) return;
const track = results[index];
console.log('🎯 Starting matched download for single track:', track);
// Open matching modal for single track
openMatchingModal(track, false, null);
}
function matchedDownloadAlbum(index) {
const results = window.currentSearchResults;
if (!results || !results[index]) return;
const album = results[index];
console.log('🎯 Starting matched download for album:', album);
// Open matching modal for album download
openMatchingModal(album, true, album);
}
function matchedDownloadAlbumTrack(albumIndex, trackIndex) {
const results = window.currentSearchResults;
if (!results || !results[albumIndex]) return;
const album = results[albumIndex];
if (!album.tracks || !album.tracks[trackIndex]) return;
const track = album.tracks[trackIndex];
// Ensure track has necessary properties from parent album
track.username = album.username;
track.artist = track.artist || album.artist;
track.album = album.album_title || album.title;
console.log('🎯 Starting matched download for album track:', track);
// Open matching modal for single track (from album context)
openMatchingModal(track, false, null);
}
function toggleAlbumExpansion(albumIndex) {
const albumCard = document.querySelector(`[data-album-index="${albumIndex}"]`);
if (!albumCard) return;
const trackList = albumCard.querySelector('.album-track-list');
const indicator = albumCard.querySelector('.album-expand-indicator');
if (trackList.style.display === 'none' || !trackList.style.display) {
// Expand
trackList.style.display = 'block';
indicator.textContent = '▼';
albumCard.classList.add('expanded');
} else {
// Collapse
trackList.style.display = 'none';
indicator.textContent = '▶';
albumCard.classList.remove('expanded');
}
}
async function downloadAlbumTrack(albumIndex, trackIndex) {
const results = window.currentSearchResults;
if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) return;
const track = results[albumIndex].tracks[trackIndex];
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...track,
result_type: 'track'
})
});
const data = await response.json();
if (data.success) {
showToast(`Download started: ${track.title}`, 'success');
} else {
showToast(`Track download failed: ${data.error}`, 'error');
}
} catch (error) {
console.error('Track download error:', error);
showToast('Failed to start track download', 'error');
}
}
// ===============================
// STREAMING WRAPPER FUNCTIONS
// ===============================
async function streamTrack(index) {
// Stream a single track from search results
try {
console.log(`🎵 streamTrack called with index: ${index}`);
console.log(`🎵 window.currentSearchResults:`, window.currentSearchResults);
if (!window.currentSearchResults || !window.currentSearchResults[index]) {
console.error(`❌ No search results or invalid index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`);
showToast('Track not found', 'error');
return;
}
const result = window.currentSearchResults[index];
console.log(`🎵 Streaming track:`, result);
// Check for unsupported formats before streaming
if (result.filename && !isAudioFormatSupported(result.filename)) {
const format = getFileExtension(result.filename);
showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error');
return;
}
await startStream(result);
} catch (error) {
console.error('Track streaming error:', error);
showToast('Failed to start track stream', 'error');
}
}
async function streamAlbumTrack(albumIndex, trackIndex) {
// Stream a specific track from an album
try {
console.log(`🎵 streamAlbumTrack called with albumIndex: ${albumIndex}, trackIndex: ${trackIndex}`);
console.log(`🎵 window.currentSearchResults:`, window.currentSearchResults);
if (!window.currentSearchResults || !window.currentSearchResults[albumIndex]) {
console.error(`❌ No search results or invalid album index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`);
showToast('Album not found', 'error');
return;
}
const album = window.currentSearchResults[albumIndex];
console.log(`🎵 Album data:`, album);
if (!album.tracks || !album.tracks[trackIndex]) {
console.error(`❌ No tracks in album or invalid track index. Tracks length: ${album.tracks ? album.tracks.length : 'undefined'}`);
showToast('Track not found in album', 'error');
return;
}
const track = album.tracks[trackIndex];
console.log(`🎵 Streaming album track:`, track);
// Ensure album tracks have required fields
const trackData = {
...track,
username: track.username || album.username,
filename: track.filename || track.path,
artist: track.artist || album.artist,
album: track.album || album.title || album.album
};
console.log(`🎵 Enhanced track data:`, trackData);
// Check for unsupported formats before streaming
if (trackData.filename && !isAudioFormatSupported(trackData.filename)) {
const format = getFileExtension(trackData.filename);
showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error');
return;
}
await startStream(trackData);
} catch (error) {
console.error('Album track streaming error:', error);
showToast('Failed to start track stream', 'error');
}
}
async function loadArtistsData() {
try {
const response = await fetch(API.artists);
const data = await response.json();
const artistsGrid = document.getElementById('artists-grid');
if (data.artists && data.artists.length) {
artistsGrid.innerHTML = data.artists.map(artist => `
<div class="artist-card">
<div class="artist-image">
${artist.image ?
`<img src="${artist.image}" alt="${escapeHtml(artist.name)}" />` :
'<div class="artist-placeholder">🎵</div>'
}
</div>
<div class="artist-info">
<div class="artist-name">${escapeHtml(artist.name)}</div>
<div class="artist-albums">${artist.album_count || 0} albums</div>
</div>
</div>
`).join('');
} else {
artistsGrid.innerHTML = '<div class="no-artists">No artists found</div>';
}
} catch (error) {
console.error('Error loading artists data:', error);
document.getElementById('artists-grid').innerHTML = '<div class="error">Error loading artists</div>';
}
}
// ===============================
// UTILITY FUNCTIONS
// ===============================
function showLoadingOverlay(message = 'Loading...') {
const overlay = document.getElementById('loading-overlay');
const messageElement = overlay.querySelector('.loading-message');
messageElement.textContent = message;
overlay.classList.remove('hidden');
}
function hideLoadingOverlay() {
document.getElementById('loading-overlay').classList.add('hidden');
}
// Toast deduplication cache
let recentToasts = new Map();
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
// Create a unique key for this toast
const toastKey = `${type}:${message}`;
const now = Date.now();
// Check if we've shown this exact toast recently (within 5 seconds)
if (recentToasts.has(toastKey)) {
const lastShown = recentToasts.get(toastKey);
if (now - lastShown < 5000) {
console.log(`🚫 Suppressing duplicate toast: "${message}"`);
return; // Don't show duplicate
}
}
// Record this toast
recentToasts.set(toastKey, now);
// Clean up old entries (older than 10 seconds)
for (const [key, timestamp] of recentToasts.entries()) {
if (now - timestamp > 10000) {
recentToasts.delete(key);
}
}
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
// Auto-remove after 3 seconds
setTimeout(() => {
if (container.contains(toast)) {
container.removeChild(toast);
}
}, 3000);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatArtists(artists) {
if (!artists || !Array.isArray(artists)) {
return 'Unknown Artist';
}
// Handle both string arrays and object arrays with 'name' property
const artistNames = artists.map(artist => {
if (typeof artist === 'string') {
return artist;
} else if (artist && typeof artist === 'object' && artist.name) {
return artist.name;
} else {
return 'Unknown Artist';
}
});
return artistNames.join(', ') || 'Unknown Artist';
}
async function showVersionInfo() {
try {
console.log('Fetching version info...');
// Fetch version data from API
const response = await fetch('/api/version-info');
if (!response.ok) {
throw new Error('Failed to fetch version info');
}
const versionData = await response.json();
console.log('Version data received:', versionData);
// Populate modal content
populateVersionModal(versionData);
// Show modal
const modalOverlay = document.getElementById('version-modal-overlay');
modalOverlay.classList.remove('hidden');
console.log('Version modal opened');
} catch (error) {
console.error('Error showing version info:', error);
showToast('Failed to load version information', 'error');
}
}
function closeVersionModal() {
const modalOverlay = document.getElementById('version-modal-overlay');
modalOverlay.classList.add('hidden');
console.log('Version modal closed');
}
function populateVersionModal(versionData) {
const container = document.getElementById('version-content-container');
if (!container) {
console.error('Version content container not found');
return;
}
// Update header with dynamic data
const titleElement = document.querySelector('.version-modal-title');
const subtitleElement = document.querySelector('.version-modal-subtitle');
if (titleElement) titleElement.textContent = versionData.title;
if (subtitleElement) subtitleElement.textContent = versionData.subtitle;
// Clear existing content
container.innerHTML = '';
// Create sections
versionData.sections.forEach(section => {
const sectionDiv = document.createElement('div');
sectionDiv.className = 'version-feature-section';
// Section title
const titleDiv = document.createElement('div');
titleDiv.className = 'version-section-title';
titleDiv.textContent = section.title;
sectionDiv.appendChild(titleDiv);
// Section description
const descDiv = document.createElement('div');
descDiv.className = 'version-section-description';
descDiv.textContent = section.description;
sectionDiv.appendChild(descDiv);
// Features list
const featuresList = document.createElement('ul');
featuresList.className = 'version-feature-list';
section.features.forEach(feature => {
const featureItem = document.createElement('li');
featureItem.className = 'version-feature-item';
featureItem.textContent = feature;
featuresList.appendChild(featureItem);
});
sectionDiv.appendChild(featuresList);
// Usage note (if present)
if (section.usage_note) {
const usageDiv = document.createElement('div');
usageDiv.className = 'version-usage-note';
usageDiv.textContent = `💡 ${section.usage_note}`;
sectionDiv.appendChild(usageDiv);
}
container.appendChild(sectionDiv);
});
console.log('Version modal content populated');
}
// ===============================
// ADDITIONAL STYLES FOR SEARCH RESULTS
// ===============================
// Add dynamic styles for search results (since they're created dynamically)
const additionalStyles = `
<style>
.search-result-item {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.search-result-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(29, 185, 84, 0.2);
}
.result-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.result-info {
flex: 1;
min-width: 0;
}
.result-title {
font-size: 14px;
font-weight: 600;
color: #ffffff;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-artist {
font-size: 12px;
color: #b3b3b3;
margin-bottom: 2px;
}
.result-album {
font-size: 11px;
color: #888888;
}
.result-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.stream-button, .download-button {
padding: 6px 12px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.stream-button {
background: rgba(29, 185, 84, 0.1);
color: #1ed760;
border: 1px solid rgba(29, 185, 84, 0.3);
}
.stream-button:hover {
background: rgba(29, 185, 84, 0.2);
border-color: rgba(29, 185, 84, 0.5);
}
.download-button {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.download-button:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.result-details {
display: flex;
gap: 16px;
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
}
.result-quality {
color: #1ed760;
font-weight: 500;
}
.no-results, .no-artists, .error {
text-align: center;
color: rgba(255, 255, 255, 0.5);
padding: 40px 20px;
font-size: 14px;
}
.artist-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
}
.artist-card:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(29, 185, 84, 0.2);
}
.artist-image {
width: 120px;
height: 120px;
margin: 0 auto 12px auto;
border-radius: 8px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
}
.artist-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: rgba(255, 255, 255, 0.3);
}
.artist-name {
font-size: 14px;
font-weight: 600;
color: #ffffff;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist-albums {
font-size: 12px;
color: #b3b3b3;
}
</style>
`;
// Inject additional styles
document.head.insertAdjacentHTML('beforeend', additionalStyles);
// Make functions available globally for onclick handlers
window.openMatchingModal = openMatchingModal;
window.closeMatchingModal = closeMatchingModal;
window.selectArtist = selectArtist;
window.selectAlbum = selectAlbum;
window.navigateToPage = navigateToPage;
window.openKofi = openKofi;
window.copyAddress = copyAddress;
window.retryLastSearch = retryLastSearch;
window.showVersionInfo = showVersionInfo;
window.closeVersionModal = closeVersionModal;
window.testConnection = testConnection;
window.autoDetectPlex = autoDetectPlex;
window.autoDetectJellyfin = autoDetectJellyfin;
window.autoDetectSlskd = autoDetectSlskd;
window.toggleServer = toggleServer;
window.authenticateTidal = authenticateTidal;
window.browsePath = browsePath;
window.selectResult = selectResult;
window.startStream = startStream;
window.streamTrack = streamTrack;
window.streamAlbumTrack = streamAlbumTrack;
window.startDownload = startDownload;
window.downloadTrack = downloadTrack;
window.downloadAlbum = downloadAlbum;
window.toggleAlbumExpansion = toggleAlbumExpansion;
window.downloadAlbumTrack = downloadAlbumTrack;
window.switchDownloadTab = switchDownloadTab;
window.cancelDownloadItem = cancelDownloadItem;
window.clearFinishedDownloads = clearFinishedDownloads;
window.matchedDownloadTrack = matchedDownloadTrack;
window.matchedDownloadAlbum = matchedDownloadAlbum;
window.matchedDownloadAlbumTrack = matchedDownloadAlbumTrack;
// Download Missing Tracks Modal functions
window.openDownloadMissingModal = openDownloadMissingModal;
window.closeDownloadMissingModal = closeDownloadMissingModal;
window.startMissingTracksProcess = startMissingTracksProcess;
window.cancelAllOperations = cancelAllOperations;
window.cancelTrackDownload = cancelTrackDownload; // Legacy system
window.cancelTrackDownloadV2 = cancelTrackDownloadV2; // NEW V2 system
window.handleViewProgressClick = handleViewProgressClick;
// Wishlist Modal functions
window.openDownloadMissingWishlistModal = openDownloadMissingWishlistModal;
window.startWishlistMissingTracksProcess = startWishlistMissingTracksProcess;
window.handleWishlistButtonClick = handleWishlistButtonClick;
// Helper functions
window.escapeHtml = escapeHtml;
window.formatArtists = formatArtists;
// Artist Download Management functions
window.closeArtistDownloadModal = closeArtistDownloadModal;
window.openArtistDownloadProcess = openArtistDownloadProcess;
window.bulkCompleteArtistDownloads = bulkCompleteArtistDownloads;
window.refreshAllArtistDownloadStatuses = refreshAllArtistDownloadStatuses;
// APPEND THIS JAVASCRIPT SNIPPET (B)
function initializeFilters() {
const toggleBtn = document.getElementById('filter-toggle-btn');
const container = document.getElementById('filters-container');
const content = document.getElementById('filter-content');
if (toggleBtn && container && content) {
// Using .onclick ensures we only ever have one click handler
toggleBtn.onclick = () => {
const isExpanded = container.classList.contains('expanded');
if (isExpanded) {
// Collapse the container
container.classList.remove('expanded');
toggleBtn.textContent = '⏷ Filters';
} else {
// Expand the container
content.classList.remove('hidden'); // Make sure content is visible for animation
container.classList.add('expanded');
toggleBtn.textContent = '⏶ Filters';
}
};
}
// This part is correct and doesn't need to change
document.querySelectorAll('.filter-btn').forEach(button => {
button.addEventListener('click', handleFilterClick);
});
}
function handleFilterClick(event) {
const button = event.target;
const filterType = button.dataset.filterType;
const value = button.dataset.value;
if (filterType === 'type') currentFilterType = value;
if (filterType === 'format') currentFilterFormat = value;
if (filterType === 'sort') currentSortBy = value;
if (button.id === 'sort-order-btn') {
isSortReversed = !isSortReversed;
button.textContent = isSortReversed ? '↑' : '↓';
}
document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => {
btn.classList.remove('active');
});
if (filterType) { // Don't try to activate the sort order button
button.classList.add('active');
}
applyFiltersAndSort();
}
function resetFilters() {
currentFilterType = 'all';
currentFilterFormat = 'all';
currentSortBy = 'quality_score';
isSortReversed = false;
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active');
document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active');
document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active');
document.getElementById('sort-order-btn').textContent = '↓';
}
function applyFiltersAndSort() {
let processedResults = [...allSearchResults];
const query = document.getElementById('downloads-search-input').value.trim().toLowerCase();
// 1. Filter by Type
if (currentFilterType !== 'all') {
processedResults = processedResults.filter(r => r.result_type === currentFilterType);
}
// 2. Filter by Format
if (currentFilterFormat !== 'all') {
processedResults = processedResults.filter(r => {
const quality = (r.dominant_quality || r.quality || '').toLowerCase();
return quality === currentFilterFormat;
});
}
// 3. Sort Results
processedResults.sort((a, b) => {
let valA, valB;
// Special handling for relevance sort
if (currentSortBy === 'relevance') {
valA = calculateRelevanceScore(a, query);
valB = calculateRelevanceScore(b, query);
return valB - valA; // Higher score is better
}
// Special handling for availability
if (currentSortBy === 'availability') {
valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1;
valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1;
return valB - valA;
}
valA = a[currentSortBy] || 0;
valB = b[currentSortBy] || 0;
if (typeof valA === 'string') {
// For name/title sort, use the correct property
const titleA = (a.album_title || a.title || '').toLowerCase();
const titleB = (b.album_title || b.title || '').toLowerCase();
return titleA.localeCompare(titleB);
}
// Default numeric sort (descending)
return valB - valA;
});
// Handle sort direction toggle
const sortDefaults = {
relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc',
upload_speed: 'desc', duration: 'desc', availability: 'desc',
title: 'asc', username: 'asc'
};
const defaultOrder = sortDefaults[currentSortBy] || 'desc';
if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) {
processedResults.reverse();
}
displayDownloadsResults(processedResults);
}
function calculateRelevanceScore(result, query) {
let score = 0.0;
const queryTerms = query.split(' ').filter(t => t.length > 1);
// 1. Search Term Matching (40%)
let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase();
let termMatches = 0;
for (const term of queryTerms) {
if (searchableText.includes(term)) {
termMatches++;
}
}
score += (termMatches / queryTerms.length) * 0.40;
// 2. Quality Score (25%)
score += (result.quality_score || 0) * 0.25;
// 3. User Reliability (Availability & Speed) (20%)
const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5;
score += reliability * 0.20;
// 4. File Completeness (Bitrate & Duration) (15%)
const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0);
score += completeness * 0.15;
return score;
}
// APPEND THIS JAVASCRIPT SNIPPET (B)
function initializeFilters() {
const toggleBtn = document.getElementById('filter-toggle-btn');
const container = document.getElementById('filters-container');
const content = document.getElementById('filter-content');
if (toggleBtn && container && content) {
// Using .onclick ensures we only ever have one click handler
toggleBtn.onclick = () => {
const isExpanded = container.classList.contains('expanded');
if (isExpanded) {
// Collapse the container
container.classList.remove('expanded');
toggleBtn.textContent = '⏷ Filters';
} else {
// Expand the container
content.classList.remove('hidden'); // Make sure content is visible for animation
container.classList.add('expanded');
toggleBtn.textContent = '⏶ Filters';
}
};
}
// This part is correct and doesn't need to change
document.querySelectorAll('.filter-btn').forEach(button => {
button.addEventListener('click', handleFilterClick);
});
}
function handleFilterClick(event) {
const button = event.target;
const filterType = button.dataset.filterType;
const value = button.dataset.value;
if (filterType === 'type') currentFilterType = value;
if (filterType === 'format') currentFilterFormat = value;
if (filterType === 'sort') currentSortBy = value;
if (button.id === 'sort-order-btn') {
isSortReversed = !isSortReversed;
button.textContent = isSortReversed ? '↑' : '↓';
}
document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => {
btn.classList.remove('active');
});
if (filterType) { // Don't try to activate the sort order button
button.classList.add('active');
}
applyFiltersAndSort();
}
function resetFilters() {
currentFilterType = 'all';
currentFilterFormat = 'all';
currentSortBy = 'quality_score';
isSortReversed = false;
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active');
document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active');
document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active');
document.getElementById('sort-order-btn').textContent = '↓';
}
function applyFiltersAndSort() {
let processedResults = [...allSearchResults];
const query = document.getElementById('downloads-search-input').value.trim().toLowerCase();
// 1. Filter by Type
if (currentFilterType !== 'all') {
processedResults = processedResults.filter(r => r.result_type === currentFilterType);
}
// 2. Filter by Format
if (currentFilterFormat !== 'all') {
processedResults = processedResults.filter(r => {
const quality = (r.dominant_quality || r.quality || '').toLowerCase();
return quality === currentFilterFormat;
});
}
// 3. Sort Results
processedResults.sort((a, b) => {
let valA, valB;
// Special handling for relevance sort
if (currentSortBy === 'relevance') {
valA = calculateRelevanceScore(a, query);
valB = calculateRelevanceScore(b, query);
return valB - valA; // Higher score is better
}
// Special handling for availability
if (currentSortBy === 'availability') {
valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1;
valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1;
return valB - valA;
}
valA = a[currentSortBy] || 0;
valB = b[currentSortBy] || 0;
if (typeof valA === 'string') {
// For name/title sort, use the correct property
const titleA = (a.album_title || a.title || '').toLowerCase();
const titleB = (b.album_title || b.title || '').toLowerCase();
return titleA.localeCompare(titleB);
}
// Default numeric sort (descending)
return valB - valA;
});
// Handle sort direction toggle
const sortDefaults = {
relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc',
upload_speed: 'desc', duration: 'desc', availability: 'desc',
title: 'asc', username: 'asc'
};
const defaultOrder = sortDefaults[currentSortBy] || 'desc';
if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) {
processedResults.reverse();
}
displayDownloadsResults(processedResults);
}
function calculateRelevanceScore(result, query) {
let score = 0.0;
const queryTerms = query.split(' ').filter(t => t.length > 1);
// 1. Search Term Matching (40%)
let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase();
let termMatches = 0;
for (const term of queryTerms) {
if (searchableText.includes(term)) {
termMatches++;
}
}
score += (termMatches / queryTerms.length) * 0.40;
// 2. Quality Score (25%)
score += (result.quality_score || 0) * 0.25;
// 3. User Reliability (Availability & Speed) (20%)
const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5;
score += reliability * 0.20;
// 4. File Completeness (Bitrate & Duration) (15%)
const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0);
score += completeness * 0.15;
return score;
}
// Add to global scope for onclick
window.handleFilterClick = handleFilterClick;
// ===============================
// MATCHED DOWNLOADS MODAL
// ===============================
// Global state for matching modal
let currentMatchingData = {
searchResult: null,
isAlbumDownload: false,
albumResult: null,
selectedArtist: null,
selectedAlbum: null,
currentStage: 'artist' // 'artist' or 'album'
};
let searchTimers = {
artist: null,
album: null
};
function openMatchingModal(searchResult, isAlbumDownload = false, albumResult = null) {
console.log('🎯 Opening matching modal for:', searchResult);
// Store the current matching data
currentMatchingData = {
searchResult: searchResult,
isAlbumDownload: isAlbumDownload,
albumResult: albumResult,
selectedArtist: null,
selectedAlbum: null,
currentStage: 'artist'
};
// Show modal
const overlay = document.getElementById('matching-modal-overlay');
overlay.classList.remove('hidden');
// Reset modal state
resetModalState();
// Set appropriate title and stage
const modalTitle = document.getElementById('matching-modal-title');
const artistStageTitle = document.getElementById('artist-stage-title');
if (isAlbumDownload) {
modalTitle.textContent = 'Match Album Download to Spotify';
artistStageTitle.textContent = 'Step 1: Select the correct Artist';
document.getElementById('album-selection-stage').style.display = 'block';
} else {
modalTitle.textContent = 'Match Download to Spotify';
artistStageTitle.textContent = 'Select the correct Artist for this Single';
document.getElementById('album-selection-stage').style.display = 'none';
}
// Generate initial artist suggestions
fetchArtistSuggestions();
// Setup event listeners
setupModalEventListeners();
}
function closeMatchingModal() {
const overlay = document.getElementById('matching-modal-overlay');
overlay.classList.add('hidden');
// Clear timers
Object.values(searchTimers).forEach(timer => {
if (timer) clearTimeout(timer);
});
// Reset state
currentMatchingData = {
searchResult: null,
isAlbumDownload: false,
albumResult: null,
selectedArtist: null,
selectedAlbum: null,
currentStage: 'artist'
};
}
function resetModalState() {
// Show artist stage, hide album stage
document.getElementById('artist-selection-stage').classList.remove('hidden');
document.getElementById('album-selection-stage').classList.add('hidden');
// Clear all suggestion containers
document.getElementById('artist-suggestions').innerHTML = '';
document.getElementById('artist-manual-results').innerHTML = '';
document.getElementById('album-suggestions').innerHTML = '';
document.getElementById('album-manual-results').innerHTML = '';
// Clear search inputs
document.getElementById('artist-search-input').value = '';
document.getElementById('album-search-input').value = '';
// Reset button states
document.getElementById('confirm-match-btn').disabled = true;
// Reset selections
currentMatchingData.selectedArtist = null;
currentMatchingData.selectedAlbum = null;
currentMatchingData.currentStage = 'artist';
}
function setupModalEventListeners() {
// Search input listeners
const artistInput = document.getElementById('artist-search-input');
const albumInput = document.getElementById('album-search-input');
artistInput.removeEventListener('input', handleArtistSearch);
artistInput.addEventListener('input', handleArtistSearch);
albumInput.removeEventListener('input', handleAlbumSearch);
albumInput.addEventListener('input', handleAlbumSearch);
// Button listeners
const skipBtn = document.getElementById('skip-matching-btn');
const cancelBtn = document.getElementById('cancel-match-btn');
const confirmBtn = document.getElementById('confirm-match-btn');
skipBtn.onclick = skipMatching;
cancelBtn.onclick = closeMatchingModal;
confirmBtn.onclick = confirmMatch;
}
async function fetchArtistSuggestions() {
try {
showLoadingCards('artist-suggestions', 'Finding artist...');
const response = await fetch('/api/match/suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_result: currentMatchingData.searchResult,
context: 'artist',
is_album: currentMatchingData.isAlbumDownload,
album_result: currentMatchingData.albumResult
})
});
const data = await response.json();
if (data.suggestions) {
renderArtistSuggestions(data.suggestions);
} else {
showNoResultsMessage('artist-suggestions', 'No artist suggestions found');
}
} catch (error) {
console.error('Error fetching artist suggestions:', error);
showNoResultsMessage('artist-suggestions', 'Error loading suggestions');
}
}
async function fetchAlbumSuggestions() {
if (!currentMatchingData.selectedArtist) return;
try {
showLoadingCards('album-suggestions', 'Finding album...');
const response = await fetch('/api/match/suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_result: currentMatchingData.searchResult,
context: 'album',
selected_artist: currentMatchingData.selectedArtist
})
});
const data = await response.json();
if (data.suggestions) {
renderAlbumSuggestions(data.suggestions);
} else {
showNoResultsMessage('album-suggestions', 'No album suggestions found');
}
} catch (error) {
console.error('Error fetching album suggestions:', error);
showNoResultsMessage('album-suggestions', 'Error loading suggestions');
}
}
function renderArtistSuggestions(suggestions) {
const container = document.getElementById('artist-suggestions');
container.innerHTML = '';
if (!suggestions.length) {
showNoResultsMessage('artist-suggestions', 'No artist matches found');
return;
}
suggestions.forEach(suggestion => {
const card = createArtistCard(suggestion.artist, suggestion.confidence);
container.appendChild(card);
});
}
function renderAlbumSuggestions(suggestions) {
const container = document.getElementById('album-suggestions');
container.innerHTML = '';
if (!suggestions.length) {
showNoResultsMessage('album-suggestions', 'No album matches found');
return;
}
suggestions.forEach(suggestion => {
const card = createAlbumCard(suggestion.album, suggestion.confidence);
container.appendChild(card);
});
}
function createArtistCard(artist, confidence) {
const card = document.createElement('div');
card.className = 'suggestion-card';
card.onclick = () => selectArtist(artist);
const imageUrl = artist.image_url || '';
const confidencePercent = Math.round(confidence * 100);
card.innerHTML = `
<div class="suggestion-card-overlay"></div>
<div class="suggestion-card-content">
<div class="suggestion-card-name" title="${escapeHtml(artist.name)}">${escapeHtml(artist.name)}</div>
<div class="suggestion-card-details">
${artist.genres && artist.genres.length ? escapeHtml(artist.genres.slice(0, 2).join(', ')) : 'Artist'}
</div>
<div class="suggestion-card-confidence">${confidencePercent}% match</div>
</div>
`;
// Set background image if available
if (imageUrl) {
card.style.backgroundImage = `url(${imageUrl})`;
card.style.backgroundSize = 'cover';
card.style.backgroundPosition = 'center';
}
return card;
}
function createAlbumCard(album, confidence) {
const card = document.createElement('div');
card.className = 'suggestion-card';
card.onclick = () => selectAlbum(album);
const imageUrl = album.image_url || '';
const confidencePercent = Math.round(confidence * 100);
const year = album.release_date ? album.release_date.split('-')[0] : '';
card.innerHTML = `
<div class="suggestion-card-overlay"></div>
<div class="suggestion-card-content">
<div class="suggestion-card-name" title="${escapeHtml(album.name)}">${escapeHtml(album.name)}</div>
<div class="suggestion-card-details">
${album.album_type ? escapeHtml(album.album_type.charAt(0).toUpperCase() + album.album_type.slice(1)) : 'Album'}${year ? `${year}` : ''}
</div>
<div class="suggestion-card-confidence">${confidencePercent}% match</div>
</div>
`;
// Set background image if available
if (imageUrl) {
card.style.backgroundImage = `url(${imageUrl})`;
card.style.backgroundSize = 'cover';
card.style.backgroundPosition = 'center';
}
return card;
}
function selectArtist(artist) {
// Clear previous selections
document.querySelectorAll('#artist-suggestions .suggestion-card').forEach(card => {
card.classList.remove('selected');
});
document.querySelectorAll('#artist-manual-results .suggestion-card').forEach(card => {
card.classList.remove('selected');
});
// Mark new selection
event.currentTarget.classList.add('selected');
// Store selection
currentMatchingData.selectedArtist = artist;
console.log('🎯 Selected artist:', artist.name);
if (currentMatchingData.isAlbumDownload) {
// Transition to album selection stage
transitionToAlbumStage();
} else {
// Enable confirm button for single downloads
document.getElementById('confirm-match-btn').disabled = false;
}
}
function selectAlbum(album) {
// Clear previous selections
document.querySelectorAll('#album-suggestions .suggestion-card').forEach(card => {
card.classList.remove('selected');
});
document.querySelectorAll('#album-manual-results .suggestion-card').forEach(card => {
card.classList.remove('selected');
});
// Mark new selection
event.currentTarget.classList.add('selected');
// Store selection
currentMatchingData.selectedAlbum = album;
console.log('🎯 Selected album:', album.name);
// Enable confirm button
document.getElementById('confirm-match-btn').disabled = false;
}
function transitionToAlbumStage() {
// Hide artist stage
document.getElementById('artist-selection-stage').classList.add('hidden');
// Show album stage
const albumStage = document.getElementById('album-selection-stage');
albumStage.classList.remove('hidden');
// Update selected artist name
document.getElementById('selected-artist-name').textContent = currentMatchingData.selectedArtist.name;
// Update current stage
currentMatchingData.currentStage = 'album';
// Fetch album suggestions
fetchAlbumSuggestions();
}
function handleArtistSearch(event) {
const query = event.target.value.trim();
// Clear previous timer
if (searchTimers.artist) {
clearTimeout(searchTimers.artist);
}
if (query.length < 2) {
document.getElementById('artist-manual-results').innerHTML = '';
return;
}
// Debounce search
searchTimers.artist = setTimeout(() => {
performArtistSearch(query);
}, 400);
}
function handleAlbumSearch(event) {
const query = event.target.value.trim();
// Clear previous timer
if (searchTimers.album) {
clearTimeout(searchTimers.album);
}
if (query.length < 2) {
document.getElementById('album-manual-results').innerHTML = '';
return;
}
// Debounce search
searchTimers.album = setTimeout(() => {
performAlbumSearch(query);
}, 400);
}
async function performArtistSearch(query) {
try {
showLoadingCards('artist-manual-results', 'Searching artists...');
const response = await fetch('/api/match/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query,
context: 'artist'
})
});
const data = await response.json();
if (data.results) {
renderArtistSearchResults(data.results);
} else {
showNoResultsMessage('artist-manual-results', 'No artists found');
}
} catch (error) {
console.error('Error searching artists:', error);
showNoResultsMessage('artist-manual-results', 'Error searching artists');
}
}
async function performAlbumSearch(query) {
if (!currentMatchingData.selectedArtist) return;
try {
showLoadingCards('album-manual-results', 'Searching albums...');
const response = await fetch('/api/match/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query,
context: 'album',
artist_id: currentMatchingData.selectedArtist.id
})
});
const data = await response.json();
if (data.results) {
renderAlbumSearchResults(data.results);
} else {
showNoResultsMessage('album-manual-results', 'No albums found');
}
} catch (error) {
console.error('Error searching albums:', error);
showNoResultsMessage('album-manual-results', 'Error searching albums');
}
}
function renderArtistSearchResults(results) {
const container = document.getElementById('artist-manual-results');
container.innerHTML = '';
results.forEach(result => {
const card = createArtistCard(result.artist, result.confidence);
container.appendChild(card);
});
}
function renderAlbumSearchResults(results) {
const container = document.getElementById('album-manual-results');
container.innerHTML = '';
results.forEach(result => {
const card = createAlbumCard(result.album, result.confidence);
container.appendChild(card);
});
}
function showLoadingCards(containerId, message) {
const container = document.getElementById(containerId);
container.innerHTML = `<div class="loading-card">${message}</div>`;
}
function showNoResultsMessage(containerId, message) {
const container = document.getElementById(containerId);
container.innerHTML = `<div class="loading-card" style="color: rgba(255,255,255,0.5)">${message}</div>`;
}
function skipMatching() {
console.log('🎯 Skipping matching, proceeding with normal download');
// Close modal
closeMatchingModal();
// Start normal download
if (currentMatchingData.isAlbumDownload) {
// For albums, we need to download each track
showToast('⬇️ Starting album download (unmatched)', 'info');
// This would need to be implemented to download all album tracks
} else {
// Single track download
startDownload(window.currentSearchResults.indexOf(currentMatchingData.searchResult));
}
}
async function confirmMatch() {
if (!currentMatchingData.selectedArtist) {
showToast('⚠️ Please select an artist first', 'error');
return;
}
if (currentMatchingData.isAlbumDownload && !currentMatchingData.selectedAlbum) {
showToast('⚠️ Please select an album first', 'error');
return;
}
const confirmBtn = document.getElementById('confirm-match-btn');
const originalText = confirmBtn.textContent; // FIX: Declare outside try block
try {
console.log('🎯 Confirming match with:', {
artist: currentMatchingData.selectedArtist.name,
album: currentMatchingData.selectedAlbum?.name
});
confirmBtn.disabled = true;
confirmBtn.textContent = 'Starting...';
// --- THIS IS THE CRITICAL FIX ---
// Determine the correct data to send. For albums, we send the full albumResult
// which contains the complete list of tracks.
const downloadPayload = currentMatchingData.isAlbumDownload
? currentMatchingData.albumResult
: currentMatchingData.searchResult;
// --- END OF FIX ---
const response = await fetch('/api/download/matched', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_result: downloadPayload, // Send the correct payload
spotify_artist: currentMatchingData.selectedArtist,
spotify_album: currentMatchingData.selectedAlbum || null
})
});
const data = await response.json();
if (data.success) {
showToast(`🎯 Matched download started for "${currentMatchingData.selectedArtist.name}"`, 'success');
closeMatchingModal();
} else {
throw new Error(data.error || 'Failed to start matched download');
}
} catch (error) {
console.error('Error starting matched download:', error);
showToast(`❌ Error starting matched download: ${error.message}`, 'error');
// Re-enable confirm button on failure
confirmBtn.disabled = false;
confirmBtn.textContent = originalText;
}
}
function matchedDownloadTrack(trackIndex) {
const results = window.currentSearchResults;
if (!results || !results[trackIndex]) {
console.error('Could not find track for matched download:', trackIndex);
showToast('Error preparing matched download.', 'error');
return;
}
const trackData = results[trackIndex];
// It's a single track, so isAlbumDownload is false and there's no album context.
openMatchingModal(trackData, false, null);
}
function matchedDownloadAlbum(albumIndex) {
const results = window.currentSearchResults;
if (!results || !results[albumIndex]) {
console.error('Could not find album for matched download:', albumIndex);
showToast('Error preparing matched download.', 'error');
return;
}
const albumData = results[albumIndex];
// The first track is used as a reference for the initial artist search.
const firstTrack = albumData.tracks ? albumData.tracks[0] : albumData;
openMatchingModal(firstTrack, true, albumData);
}
function matchedDownloadAlbumTrack(albumIndex, trackIndex) {
const results = window.currentSearchResults;
if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) {
console.error('Could not find album track for matched download:', albumIndex, trackIndex);
showToast('Error preparing matched download.', 'error');
return;
}
const albumData = results[albumIndex];
const trackData = albumData.tracks[trackIndex];
// This is the definitive fix.
// The second argument MUST be 'false' to treat this as a single track download,
// which prevents the modal from asking for an album selection.
openMatchingModal(trackData, false, albumData);
}
// ===========================================
// == DASHBOARD DATABASE UPDATER FUNCTIONALITY ==
// ===========================================
// --- State and Polling Management ---
function stopDbStatsPolling() {
if (dbStatsInterval) {
clearInterval(dbStatsInterval);
dbStatsInterval = null;
}
}
function stopDbUpdatePolling() {
if (dbUpdateStatusInterval) {
clearInterval(dbUpdateStatusInterval);
dbUpdateStatusInterval = null;
}
}
function stopWishlistCountPolling() {
if (wishlistCountInterval) {
clearInterval(wishlistCountInterval);
wishlistCountInterval = null;
}
}
function resetWishlistModalToIdleState() {
// Reset wishlist modal to idle state after background processing completes
const playlistId = 'wishlist';
const process = activeDownloadProcesses[playlistId];
if (process) {
console.log('🔄 Resetting wishlist modal to idle state...');
// Reset button states
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) {
beginBtn.style.display = 'inline-block';
beginBtn.disabled = false;
beginBtn.textContent = 'Begin Analysis';
}
if (cancelBtn) {
cancelBtn.style.display = 'none';
}
// Reset progress displays
const analysisText = document.getElementById(`analysis-progress-text-${playlistId}`);
const analysisBar = document.getElementById(`analysis-progress-fill-${playlistId}`);
const downloadText = document.getElementById(`download-progress-text-${playlistId}`);
const downloadBar = document.getElementById(`download-progress-fill-${playlistId}`);
if (analysisText) analysisText.textContent = 'Ready to start';
if (analysisBar) analysisBar.style.width = '0%';
if (downloadText) downloadText.textContent = 'Waiting for analysis';
if (downloadBar) downloadBar.style.width = '0%';
// Reset all track rows to pending state
const trackRows = document.querySelectorAll(`#download-missing-modal-${playlistId} tr[data-track-index]`);
trackRows.forEach((row, index) => {
const matchCell = row.querySelector(`#match-${playlistId}-${index}`);
const downloadCell = row.querySelector(`#download-${playlistId}-${index}`);
const actionsCell = row.querySelector(`#actions-${playlistId}-${index}`);
if (matchCell) matchCell.textContent = '🔍 Pending';
if (downloadCell) downloadCell.textContent = '-';
if (actionsCell) actionsCell.innerHTML = '-';
});
// Reset stats
const foundElement = document.getElementById(`stat-found-${playlistId}`);
const missingElement = document.getElementById(`stat-missing-${playlistId}`);
const downloadedElement = document.getElementById(`stat-downloaded-${playlistId}`);
if (foundElement) foundElement.textContent = '-';
if (missingElement) missingElement.textContent = '-';
if (downloadedElement) downloadedElement.textContent = '0';
// Reset process status
process.status = 'idle';
process.batchId = null;
if (process.poller) {
clearInterval(process.poller);
process.poller = null;
}
console.log('✅ Wishlist modal fully reset to idle state');
} else {
console.log('⚠️ No wishlist process found to reset');
}
}
async function loadDashboardData() {
// Attach event listeners for the DB updater tool
const updateButton = document.getElementById('db-update-button');
if (updateButton) {
updateButton.addEventListener('click', handleDbUpdateButtonClick);
}
// Attach event listener for the wishlist button
const wishlistButton = document.getElementById('wishlist-button');
if (wishlistButton) {
wishlistButton.addEventListener('click', handleWishlistButtonClick);
}
// Initial load of stats
await fetchAndUpdateDbStats();
// Start periodic refresh of stats (every 30 seconds)
stopDbStatsPolling(); // Ensure no duplicates
dbStatsInterval = setInterval(fetchAndUpdateDbStats, 30000);
// Initial load of wishlist count
await updateWishlistCount();
// Start periodic refresh of wishlist count (every 30 seconds, matching GUI behavior)
stopWishlistCountPolling(); // Ensure no duplicates
wishlistCountInterval = setInterval(updateWishlistCount, 30000);
// Also check the status of any ongoing update when the page loads
await checkAndUpdateDbProgress();
// Check for any active download processes that need rehydration
await checkForActiveProcesses();
// Automatic wishlist processing now runs server-side
}
// --- Data Fetching and UI Updates ---
async function fetchAndUpdateDbStats() {
try {
const response = await fetch('/api/database/stats');
if (!response.ok) return;
const stats = await response.json();
// This function updates the stat cards in the top grid
updateDashboardStatCards(stats);
// This function updates the info within the DB Updater tool card
updateDbUpdaterCardInfo(stats);
} catch (error) {
console.warn('Could not fetch DB stats:', error);
}
}
function updateDashboardStatCards(stats) {
// You can expand this later to update the main stat cards
// For now, we focus on the updater tool itself.
}
function updateDbUpdaterCardInfo(stats) {
// Update the detailed stats within the DB Updater tool card
const lastRefreshEl = document.getElementById('db-last-refresh');
const artistsStatEl = document.getElementById('db-stat-artists');
const albumsStatEl = document.getElementById('db-stat-albums');
const tracksStatEl = document.getElementById('db-stat-tracks');
const sizeStatEl = document.getElementById('db-stat-size');
if (lastRefreshEl) {
if (stats.last_full_refresh) {
const date = new Date(stats.last_full_refresh);
lastRefreshEl.textContent = date.toLocaleString();
} else {
lastRefreshEl.textContent = 'Never';
}
}
if (artistsStatEl) artistsStatEl.textContent = stats.artists.toLocaleString() || '0';
if (albumsStatEl) albumsStatEl.textContent = stats.albums.toLocaleString() || '0';
if (tracksStatEl) tracksStatEl.textContent = stats.tracks.toLocaleString() || '0';
if (sizeStatEl) sizeStatEl.textContent = `${stats.database_size_mb.toFixed(2)} MB`;
// Update the title of the tool card to show which server is active
const toolCardTitle = document.querySelector('#db-updater-card .tool-card-title');
if (toolCardTitle && stats.server_source) {
const serverName = stats.server_source.charAt(0).toUpperCase() + stats.server_source.slice(1);
toolCardTitle.textContent = `${serverName} Database Updater`;
}
}
// --- Wishlist Count Functions ---
async function updateWishlistCount() {
try {
const response = await fetch('/api/wishlist/count');
if (!response.ok) return;
const data = await response.json();
const count = data.count || 0;
const wishlistButton = document.getElementById('wishlist-button');
if (wishlistButton) {
wishlistButton.textContent = `🎵 Wishlist (${count})`;
// Update button styling based on count (matching GUI behavior)
if (count === 0) {
wishlistButton.classList.remove('wishlist-active');
wishlistButton.classList.add('wishlist-inactive');
} else {
wishlistButton.classList.remove('wishlist-inactive');
wishlistButton.classList.add('wishlist-active');
}
}
// Check for auto-initiated wishlist processes that user should see immediately
await checkForAutoInitiatedWishlistProcess();
} catch (error) {
console.warn('Could not fetch wishlist count:', error);
}
}
async function checkForAutoInitiatedWishlistProcess() {
try {
const playlistId = 'wishlist';
// Only check if we're on the dashboard and no modal is currently visible
if (currentPage !== 'dashboard') {
return;
}
// Don't override if user has manually closed the modal during auto-processing
if (WishlistModalState.wasUserClosed()) {
return;
}
// Check for active wishlist processes
const response = await fetch('/api/active-processes');
if (!response.ok) return;
const data = await response.json();
const processes = data.active_processes || [];
const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId);
const clientWishlistProcess = activeDownloadProcesses[playlistId];
if (serverWishlistProcess && serverWishlistProcess.auto_initiated) {
console.log('🤖 [Auto-Processing] Detected auto-initiated wishlist process during polling');
// Only sync frontend state if needed, but don't auto-show modal
const needsSync = !clientWishlistProcess ||
clientWishlistProcess.batchId !== serverWishlistProcess.batch_id ||
!clientWishlistProcess.modalElement ||
!document.body.contains(clientWishlistProcess.modalElement);
if (needsSync) {
console.log('🔄 [Auto-Processing] Syncing frontend state for auto-processing (background mode)');
await rehydrateModal(serverWishlistProcess, false); // Background sync only
}
// Note: Modal visibility is controlled by user interaction only
// User must click wishlist button to see auto-processing progress
}
} catch (error) {
console.warn('Error checking for auto-initiated wishlist process:', error);
}
}
async function checkAndUpdateDbProgress() {
try {
const response = await fetch('/api/database/update/status');
if (!response.ok) return;
const state = await response.json();
updateDbProgressUI(state);
if (state.status === 'running') {
// If an update is running, start polling for progress
stopDbUpdatePolling();
dbUpdateStatusInterval = setInterval(checkAndUpdateDbProgress, 1000);
}
} catch (error) {
console.warn('Could not fetch DB update status:', error);
}
}
function updateDbProgressUI(state) {
const button = document.getElementById('db-update-button');
const phaseLabel = document.getElementById('db-phase-label');
const progressLabel = document.getElementById('db-progress-label');
const progressBar = document.getElementById('db-progress-bar');
const refreshSelect = document.getElementById('db-refresh-type');
if (!button || !phaseLabel || !progressLabel || !progressBar || !refreshSelect) return;
if (state.status === 'running') {
button.textContent = 'Stop Update';
button.disabled = false;
refreshSelect.disabled = true;
phaseLabel.textContent = state.phase || 'Processing...';
progressLabel.textContent = `${state.processed} / ${state.total} artists (${state.progress.toFixed(1)}%)`;
progressBar.style.width = `${state.progress}%`;
} else { // idle, finished, or error
stopDbUpdatePolling();
button.textContent = 'Update Database';
button.disabled = false;
refreshSelect.disabled = false;
if (state.status === 'error') {
phaseLabel.textContent = `Error: ${state.error_message}`;
progressBar.style.backgroundColor = '#ff4444'; // Red for error
} else {
phaseLabel.textContent = state.phase || 'Idle';
progressBar.style.backgroundColor = '#1db954'; // Green for normal
}
if (state.status === 'finished' || state.status === 'error') {
// Final stats refresh after completion/error
setTimeout(fetchAndUpdateDbStats, 500);
}
}
}
// ===================================================================
// 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`);
// 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') {
// No need to fetch data - we already have all tracks from initial load (like sync.py)
console.log(`🎵 Using pre-loaded Tidal playlist data for: ${state.playlist.name}`);
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 = `[Tidal] ${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;
(downloadData.tasks || []).forEach(task => {
const row = document.querySelector(`#download-missing-modal-${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 'failed': statusText = '❌ Failed'; failedOrCancelledCount++; break;
case 'cancelled': statusText = '🚫 Cancelled'; failedOrCancelledCount++; break;
default: statusText = `${task.status}`; break;
}
if (statusEl) statusEl.textContent = statusText;
if (actionsEl) actionsEl.innerHTML = '-'; // Remove action buttons for completed tasks
});
// Update download progress to final state
const totalFinished = completedCount + failedOrCancelledCount;
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, ${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) => {
const isFound = result.status === 'found';
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 : '-',
spotify_artist: result.spotify_data ? result.spotify_data.artists.join(', ') : '-',
spotify_album: result.spotify_data ? result.spotify_data.album : '-'
};
});
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]);
}
const pollInterval = setInterval(async () => {
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,
results: status.results.map((result, index) => ({
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: result.status === 'found' ? '✅ Found' : '❌ Not Found',
status_class: result.status === 'found' ? 'found' : 'not-found',
spotify_track: result.spotify_data ? result.spotify_data.name : '-',
spotify_artist: result.spotify_data ? result.spotify_data.artists.join(', ') : '-',
spotify_album: result.spotify_data ? result.spotify_data.album : '-'
}))
};
// 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,
`[Tidal] ${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);
}
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;
}
// 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);
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) {
// Stop any existing polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
const state = youtubePlaylistStates[urlHash];
const playlistId = state.tidal_playlist_id;
const pollInterval = setInterval(async () => {
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;
}
// Update card progress with sync stats
updateTidalCardSyncProgress(playlistId, status.progress);
// Update modal sync display if open
updateTidalModalSyncProgress(urlHash, status.progress);
// Check if complete
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
// Update both states to sync_complete
if (tidalPlaylistStates[playlistId]) {
tidalPlaylistStates[playlistId].phase = 'sync_complete';
}
if (youtubePlaylistStates[urlHash]) {
youtubePlaylistStates[urlHash].phase = 'sync_complete';
}
// Update card phase to sync complete
updateTidalCardPhase(playlistId, 'sync_complete');
// Update modal buttons
updateTidalModalButtons(urlHash, 'sync_complete');
console.log('✅ Tidal sync complete:', urlHash);
showToast('Tidal playlist sync complete!', 'success');
} else if (status.sync_status === 'error') {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
// Update both states to discovered (revert on error)
if (tidalPlaylistStates[playlistId]) {
tidalPlaylistStates[playlistId].phase = 'discovered';
}
if (youtubePlaylistStates[urlHash]) {
youtubePlaylistStates[urlHash].phase = 'discovered';
}
// Revert to discovered phase on error
updateTidalCardPhase(playlistId, 'discovered');
updateTidalModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('❌ Error polling Tidal sync:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
}
}, 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];
}
// 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;
}
// Get the actual Tidal playlist ID
const tidalPlaylistId = state.tidal_playlist_id;
const tidalState = tidalPlaylistStates[tidalPlaylistId];
if (!tidalState || !tidalState.discovery_results) {
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 tidalState.discovery_results) {
if (result.spotify_data) {
spotifyTracks.push(result.spotify_data);
}
}
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
// Create a virtual playlist for the download system
const virtualPlaylistId = `tidal_${tidalPlaylistId}`;
const playlistName = `[Tidal] ${tidalState.playlist.name}`;
// Store reference for card navigation (same as YouTube)
tidalState.convertedSpotifyPlaylistId = virtualPlaylistId;
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) {
// 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
};
// Use the exact same modal HTML structure as the existing Spotify modal
modal.innerHTML = `
<div class="download-missing-modal-content">
<div class="download-missing-modal-header">
<h2 class="download-missing-modal-title">Download Missing Tracks - ${escapeHtml(playlistName)}</h2>
<span class="download-missing-modal-close" onclick="closeDownloadMissingModal('${virtualPlaylistId}')">&times;</span>
</div>
<div class="download-missing-modal-body">
<div class="download-dashboard-stats">
<div class="dashboard-stat stat-total">
<div class="dashboard-stat-number" id="stat-total-${virtualPlaylistId}">${spotifyTracks.length}</div>
<div class="dashboard-stat-label">Total Tracks</div>
</div>
<div class="dashboard-stat stat-found">
<div class="dashboard-stat-number" id="stat-found-${virtualPlaylistId}">-</div>
<div class="dashboard-stat-label">Found in Library</div>
</div>
<div class="dashboard-stat stat-missing">
<div class="dashboard-stat-number" id="stat-missing-${virtualPlaylistId}">-</div>
<div class="dashboard-stat-label">Missing Tracks</div>
</div>
<div class="dashboard-stat stat-downloaded">
<div class="dashboard-stat-number" id="stat-downloaded-${virtualPlaylistId}">0</div>
<div class="dashboard-stat-label">Downloaded</div>
</div>
</div>
<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>
</div>
<div class="download-tracks-table-container">
<table class="download-tracks-table">
<thead>
<tr>
<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-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(track.artists.join(', '))}">${track.artists.join(', ')}</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">
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
Begin Analysis
</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>
`;
modal.style.display = 'flex';
}
// ===============================
// SYNC PAGE FUNCTIONALITY (REDESIGNED)
// ===============================
function initializeSyncPage() {
// Logic for tab switching
const tabButtons = document.querySelectorAll('.sync-tab-button');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabId = button.dataset.tab;
// 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');
});
});
// 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 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();
}
});
}
}
// --- 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 = confirm("⚠️ Full Refresh Warning!\n\nThis will clear and rebuild the database for the active server. It can take a long time. Are you sure you want to 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: Create fresh modal for new wishlist process
console.log(`🆕 [Wishlist Button] Creating fresh modal for ${countData.count} wishlist tracks`);
await openDownloadMissingWishlistModal();
} catch (error) {
console.error('❌ [Wishlist Button] Error handling wishlist button click:', error);
showToast(`Error opening wishlist: ${error.message}`, 'error');
}
}
async function clearWishlist(playlistId) {
try {
// Show confirmation dialog
const confirmed = confirm(
"Clear Wishlist\n\n" +
"Are you sure you want to clear the entire wishlist?\n\n" +
"This will permanently remove all failed tracks from the wishlist. " +
"This action cannot be undone."
);
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';
}
}
}
// ===============================
// 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;
}
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)`);
// Update card with parsed data and stay in 'fresh' phase
updateYouTubeCardData(result.url_hash, result);
updateYouTubeCardPhase(result.url_hash, 'fresh');
// 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 = '#1db954'; // 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 = '#1db954'; // 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 = '#1db954'; // 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 = `[YouTube] ${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 = `[YouTube] ${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;
}
// 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]);
}
const pollInterval = setInterval(async () => {
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.discoveryProgress = status.progress || 0;
state.spotifyMatches = status.spotify_matches || 0;
}
// Update modal if open
updateYouTubeDiscoveryModal(urlHash, status);
// Check if complete
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
// Update card phase to discovered
updateYouTubeCardPhase(urlHash, 'discovered');
// Update modal buttons to show sync and download buttons
updateYouTubeModalButtons(urlHash, 'discovered');
console.log('✅ YouTube discovery complete:', urlHash);
showToast('YouTube 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) {
const state = youtubePlaylistStates[urlHash];
if (!state || !state.playlist) {
console.error('❌ No YouTube playlist data found for hash:', urlHash);
return;
}
console.log('🎵 Opening YouTube 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');
// Resume polling if discovery is in progress
if (state.phase === 'discovering' && !activeYouTubePollers[urlHash]) {
console.log('🔄 Resuming discovery polling...');
startYouTubeDiscoveryPolling(urlHash);
}
} else {
// Create new modal (support both YouTube and Tidal like sync.py)
const isTidal = state.is_tidal_playlist;
const modalTitle = isTidal ? '🎵 Tidal Playlist Discovery' : '🎵 YouTube Playlist Discovery';
const sourceLabel = isTidal ? 'Tidal' : '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)}</div>
<button class="modal-close-btn" onclick="closeYouTubeDiscoveryModal('${urlHash}')">✕</button>
</div>
<div class="modal-body">
<div class="progress-section">
<div class="progress-label">🔍 Spotify 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)}</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>Spotify Track</th>
<th>Spotify Artist</th>
<th>Album</th>
${isTidal ? '' : '<th>Duration</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>
</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) {
const progressData = {
progress: state.discoveryProgress || 0,
spotify_matches: state.spotifyMatches || 0,
spotify_total: state.playlist.tracks.length,
results: state.discoveryResults
};
updateYouTubeDiscoveryModal(urlHash, progressData);
}
console.log('✨ Created new modal with current state');
}
}
function getModalActionButtons(urlHash, phase, state = null) {
// Get state if not provided
if (!state) {
state = youtubePlaylistStates[urlHash];
}
const isTidal = state && state.is_tidal_playlist;
// Validate data availability for buttons
const hasDiscoveryResults = state && state.discoveryResults && state.discoveryResults.length > 0;
const hasSpotifyMatches = state && state.spotifyMatches > 0;
const hasConvertedPlaylistId = state && state.convertedSpotifyPlaylistId;
switch (phase) {
case 'discovered':
// 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
if (hasSpotifyMatches) {
if (isTidal) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startTidalPlaylistSync('${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 (isTidal) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startTidalDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
} else {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
}
}
if (!buttons) {
buttons = `<div class="modal-info"> No Spotify matches found. Discovery complete but no tracks could be matched.</div>`;
}
return buttons;
case 'syncing':
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 {
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
if (hasSpotifyMatches) {
if (isTidal) {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startTidalPlaylistSync('${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 (isTidal) {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startTidalDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
} else {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
}
}
if (isTidal) {
// Tidal doesn't have a reset function yet, but could be added
// syncCompleteButtons += `<button class="modal-btn modal-btn-secondary" onclick="resetTidalPlaylist('${urlHash}')">🔄 Reset</button>`;
} else {
syncCompleteButtons += `<button class="modal-btn modal-btn-secondary" onclick="resetYouTubePlaylist('${urlHash}')">🔄 Reset</button>`;
}
return syncCompleteButtons;
default:
return '';
}
}
function getModalDescription(phase, isTidal = false) {
const source = isTidal ? 'Tidal' : 'YouTube';
switch (phase) {
case 'fresh':
return `Ready to discover clean Spotify metadata for ${source} tracks...`;
case 'discovering':
return `Discovering clean Spotify metadata for ${source} tracks...`;
case 'discovered':
return 'Discovery complete! View the results below.';
default:
return `Discovering clean Spotify metadata for ${source} tracks...`;
}
}
function getInitialProgressText(phase, isTidal = false) {
switch (phase) {
case 'fresh':
return 'Click Start Discovery to begin...';
case 'discovering':
return 'Starting discovery...';
case 'discovered':
return 'Discovery completed!';
default:
return 'Starting discovery...';
}
}
function generateTableRowsFromState(state, urlHash) {
const isTidal = state.is_tidal_playlist;
if (state.discoveryResults && state.discoveryResults.length > 0) {
// Generate rows from existing discovery results
return state.discoveryResults.map((result, index) => `
<tr id="discovery-row-${urlHash}-${result.index}">
<td class="yt-track">${result.yt_track}</td>
<td class="yt-artist">${result.yt_artist}</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>
${isTidal ? '' : `<td class="duration">${result.duration}</td>`}
</tr>
`).join('');
} else {
// Generate initial rows from playlist tracks
return generateInitialTableRows(state.playlist.tracks, isTidal, urlHash);
}
}
function generateInitialTableRows(tracks, isTidal = false, urlHash = '') {
return tracks.map((track, index) => `
<tr id="discovery-row-${urlHash}-${index}">
<td class="yt-track">${track.name}</td>
<td class="yt-artist">${track.artists ? (Array.isArray(track.artists) ? track.artists.join(', ') : track.artists) : 'Unknown Artist'}</td>
<td class="discovery-status">🔍 Pending...</td>
<td class="spotify-track">-</td>
<td class="spotify-artist">-</td>
<td class="spotify-album">-</td>
${isTidal ? '' : `<td class="duration">${formatDuration(track.duration_ms)}</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')}`;
}
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');
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 action buttons if discovery is complete (progress = 100%)
if (status.progress >= 100) {
const state = youtubePlaylistStates[urlHash];
if (state && 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}`);
}
}
}
}
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);
}
// 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;
}
// 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);
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) {
// Stop any existing polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
}
const pollInterval = setInterval(async () => {
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;
}
// Update card progress with sync stats
updateYouTubeCardSyncProgress(urlHash, status.progress);
// Update modal sync display if open
updateYouTubeModalSyncProgress(urlHash, status.progress);
// Check if complete
if (status.complete) {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
// Update card phase to sync complete
updateYouTubeCardPhase(urlHash, 'sync_complete');
// Update modal buttons
updateYouTubeModalButtons(urlHash, 'sync_complete');
console.log('✅ YouTube sync complete:', urlHash);
showToast('YouTube playlist sync complete!', 'success');
} else if (status.sync_status === 'error') {
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
// Revert to discovered phase on error
updateYouTubeCardPhase(urlHash, 'discovered');
updateYouTubeModalButtons(urlHash, 'discovered');
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('❌ Error polling YouTube sync:', error);
clearInterval(pollInterval);
delete activeYouTubePollers[urlHash];
}
}, 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];
}
// 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) {
const statusDisplay = document.getElementById(`youtube-sync-status-${urlHash}`);
if (!statusDisplay || !progress) return;
console.log(`📊 Updating YouTube modal sync progress for ${urlHash}:`, progress);
// Update individual counters exactly like Spotify sync
const totalEl = document.getElementById(`youtube-total-${urlHash}`);
const matchedEl = document.getElementById(`youtube-matched-${urlHash}`);
const failedEl = document.getElementById(`youtube-failed-${urlHash}`);
const percentageEl = document.getElementById(`youtube-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 for YouTube playlist:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.discoveryResults) {
showToast('No discovery results available for download', 'error');
return;
}
// Convert YouTube results to a format compatible with the download modal
const spotifyTracks = state.discoveryResults
.filter(result => result.spotify_data)
.map(result => result.spotify_data);
if (spotifyTracks.length === 0) {
showToast('No Spotify matches found for download', 'error');
return;
}
// Create a virtual playlist for the download system
const virtualPlaylistId = `youtube_${urlHash}`;
const playlistName = `[YouTube] ${state.playlist.name}`;
// Store reference for card navigation
state.convertedSpotifyPlaylistId = virtualPlaylistId;
// 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');
}
}
// ============================================================================
// ARTISTS PAGE FUNCTIONALITY - ELEGANT SEARCH & DISCOVERY
// ============================================================================
/**
* Initialize the artists page when navigated to (only runs once)
*/
function initializeArtistsPage() {
console.log('🎵 Initializing Artists Page (first time)');
// Get DOM elements
const searchInput = document.getElementById('artists-search-input');
const headerSearchInput = document.getElementById('artists-header-search-input');
const searchStatus = document.getElementById('artists-search-status');
const backButton = document.getElementById('artists-back-button');
const detailBackButton = document.getElementById('artist-detail-back-button');
// Set up event listeners (only need to do this once)
if (searchInput) {
searchInput.addEventListener('input', handleArtistsSearchInput);
searchInput.addEventListener('keypress', handleArtistsSearchKeypress);
}
if (headerSearchInput) {
headerSearchInput.addEventListener('input', handleArtistsHeaderSearchInput);
headerSearchInput.addEventListener('keypress', handleArtistsSearchKeypress);
}
if (backButton) {
backButton.addEventListener('click', () => showArtistsSearchState());
}
if (detailBackButton) {
detailBackButton.addEventListener('click', () => showArtistsResultsState());
}
// Initialize tabs (only need to do this once)
initializeArtistTabs();
// Mark as initialized
artistsPageState.isInitialized = true;
// Restore previous state instead of always resetting to search
restoreArtistsPageState();
console.log('✅ Artists Page initialized successfully (ready for navigation)');
}
/**
* Restore the artists page to its previous state
*/
function restoreArtistsPageState() {
console.log(`🔄 Restoring artists page state: ${artistsPageState.currentView}`);
switch (artistsPageState.currentView) {
case 'results':
// Restore search results state
if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) {
console.log(`📦 Restoring search results for: "${artistsPageState.searchQuery}"`);
// Restore search input values
const searchInput = document.getElementById('artists-search-input');
const headerSearchInput = document.getElementById('artists-header-search-input');
if (searchInput) searchInput.value = artistsPageState.searchQuery;
if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery;
// Display the cached results
displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults);
} else {
// No valid results state, fall back to search
showArtistsSearchState();
}
break;
case 'detail':
// Restore artist detail state
if (artistsPageState.selectedArtist && artistsPageState.artistDiscography) {
console.log(`🎤 Restoring artist detail for: ${artistsPageState.selectedArtist.name}`);
// First restore search results if they exist
if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) {
const searchInput = document.getElementById('artists-search-input');
const headerSearchInput = document.getElementById('artists-header-search-input');
if (searchInput) searchInput.value = artistsPageState.searchQuery;
if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery;
}
// Show artist detail state
showArtistDetailState();
// Update artist info in header
updateArtistDetailHeader(artistsPageState.selectedArtist);
// Display cached discography
if (artistsPageState.artistDiscography.albums || artistsPageState.artistDiscography.singles) {
displayArtistDiscography(artistsPageState.artistDiscography);
// Restore cached completion data instead of re-scanning
restoreCachedCompletionData(artistsPageState.selectedArtist.id);
}
} else {
// No valid detail state, fall back to search or results
if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) {
displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults);
} else {
showArtistsSearchState();
}
}
break;
default:
case 'search':
// Show search state (but preserve any existing search query)
if (artistsPageState.searchQuery) {
const searchInput = document.getElementById('artists-search-input');
if (searchInput) searchInput.value = artistsPageState.searchQuery;
}
showArtistsSearchState();
break;
}
}
/**
* Handle search input with debouncing
*/
function handleArtistsSearchInput(event) {
const query = event.target.value.trim();
updateArtistsSearchStatus('searching');
// Clear existing timeout
if (artistsSearchTimeout) {
clearTimeout(artistsSearchTimeout);
}
// Cancel any active search
if (artistsSearchController) {
artistsSearchController.abort();
}
if (query === '') {
updateArtistsSearchStatus('default');
return;
}
// Set up new debounced search
artistsSearchTimeout = setTimeout(() => {
performArtistsSearch(query);
}, 1000); // 1 second debounce
}
/**
* Handle header search input (already in results state)
*/
function handleArtistsHeaderSearchInput(event) {
const query = event.target.value.trim();
// Update main search input to match
const mainInput = document.getElementById('artists-search-input');
if (mainInput) {
mainInput.value = query;
}
// Trigger search with same debouncing logic
handleArtistsSearchInput(event);
}
/**
* Handle Enter key press in search inputs
*/
function handleArtistsSearchKeypress(event) {
if (event.key === 'Enter') {
event.preventDefault();
const query = event.target.value.trim();
if (query && query !== artistsPageState.searchQuery) {
// Clear timeout and search immediately
if (artistsSearchTimeout) {
clearTimeout(artistsSearchTimeout);
}
performArtistsSearch(query);
}
}
}
/**
* Perform artist search with API call
*/
async function performArtistsSearch(query) {
console.log(`🔍 Searching for artists: "${query}"`);
// Check cache first
if (artistsPageState.cache.searches[query]) {
console.log('📦 Using cached search results');
displayArtistsResults(query, artistsPageState.cache.searches[query]);
return;
}
// Update status
updateArtistsSearchStatus('searching');
// Show loading cards immediately if we're in results view
if (artistsPageState.currentView === 'results') {
showSearchLoadingCards();
}
try {
// Set up abort controller
artistsSearchController = new AbortController();
const response = await fetch('/api/match/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: query,
context: 'artist'
}),
signal: artistsSearchController.signal
});
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}
const data = await response.json();
console.log(`✅ Found ${data.results?.length || 0} artists`);
// Transform the results to flatten the nested artist data
const transformedResults = (data.results || []).map(result => {
// Extract artist data from the nested structure
const artist = result.artist || result;
return {
id: artist.id,
name: artist.name,
image_url: artist.image_url,
genres: artist.genres,
popularity: artist.popularity,
confidence: result.confidence || 0
};
});
console.log('🔧 Transformed results:', transformedResults);
// Cache the transformed results
artistsPageState.cache.searches[query] = transformedResults;
// Display results
displayArtistsResults(query, transformedResults);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('❌ Artist search failed:', error);
// Provide specific error messages based on the error type
let errorMessage = 'Search failed. Please try again.';
if (error.message.includes('401') || error.message.includes('authentication')) {
errorMessage = 'Spotify not authenticated. Please check your API settings.';
} else if (error.message.includes('network') || error.message.includes('fetch')) {
errorMessage = 'Network error. Please check your connection.';
} else if (error.message.includes('timeout')) {
errorMessage = 'Search timed out. Please try again.';
}
updateArtistsSearchStatus('error', errorMessage);
}
} finally {
artistsSearchController = null;
}
}
/**
* Display artist search results
*/
function displayArtistsResults(query, results) {
console.log(`📊 Displaying ${results.length} artist results`);
// Update state
artistsPageState.searchQuery = query;
artistsPageState.searchResults = results;
artistsPageState.currentView = 'results';
// Update header search input if different
const headerInput = document.getElementById('artists-header-search-input');
if (headerInput && headerInput.value !== query) {
headerInput.value = query;
}
// Show results state
showArtistsResultsState();
// Populate results
const container = document.getElementById('artists-cards-container');
if (!container) return;
if (results.length === 0) {
container.innerHTML = `
<div style="grid-column: 1 / -1; text-align: center; padding: 60px 20px; color: rgba(255, 255, 255, 0.6);">
<div style="font-size: 24px; margin-bottom: 12px;">🔍</div>
<div style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">No artists found</div>
<div style="font-size: 14px;">Try a different search term</div>
</div>
`;
return;
}
// Create artist cards
container.innerHTML = results.map(result => createArtistCard(result)).join('');
// Add event listeners to cards
container.querySelectorAll('.artist-card').forEach((card, index) => {
card.addEventListener('click', () => selectArtist(results[index]));
// Extract colors from artist image for dynamic glow
const artist = results[index];
if (artist.image_url) {
extractImageColors(artist.image_url, (colors) => {
applyDynamicGlow(card, colors);
});
}
});
// Add mouse wheel horizontal scrolling
container.addEventListener('wheel', (event) => {
if (event.deltaY !== 0) {
event.preventDefault();
container.scrollLeft += event.deltaY;
}
});
}
/**
* Create HTML for an artist card
*/
function createArtistCard(artist) {
const imageUrl = artist.image_url || '';
const genres = artist.genres && artist.genres.length > 0 ?
artist.genres.slice(0, 3).join(', ') : 'Various genres';
const popularity = artist.popularity || 0;
// Create a fallback gradient if no image is available
const backgroundStyle = imageUrl ?
`background-image: url('${imageUrl}');` :
`background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
// Format popularity as a percentage for better UX
const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown';
return `
<div class="artist-card" data-artist-id="${artist.id}">
<div class="artist-card-background" style="${backgroundStyle}"></div>
<div class="artist-card-overlay"></div>
<div class="artist-card-content">
<div class="artist-card-name">${escapeHtml(artist.name)}</div>
<div class="artist-card-genres">${escapeHtml(genres)}</div>
<div class="artist-card-popularity">
<span class="popularity-icon">🔥</span>
<span>${popularityText}</span>
</div>
</div>
</div>
`;
}
/**
* Select an artist and show their discography
*/
async function selectArtist(artist) {
console.log(`🎤 Selected artist: ${artist.name}`);
// Update state
artistsPageState.selectedArtist = artist;
artistsPageState.currentView = 'detail';
// Show detail state
showArtistDetailState();
// Update artist info in header
updateArtistDetailHeader(artist);
// Load discography
await loadArtistDiscography(artist.id);
}
/**
* Load artist's discography from Spotify
*/
async function loadArtistDiscography(artistId) {
console.log(`💿 Loading discography for artist: ${artistId}`);
// Check cache first
if (artistsPageState.cache.discography[artistId]) {
console.log('📦 Using cached discography');
const cachedDiscography = artistsPageState.cache.discography[artistId];
displayArtistDiscography(cachedDiscography);
// Still check completion status for cached data
await checkDiscographyCompletion(artistId, cachedDiscography);
return;
}
try {
// Show loading states
showDiscographyLoading();
// Call the real API endpoint
const response = await fetch(`/api/artist/${artistId}/discography`);
if (!response.ok) {
if (response.status === 401) {
throw new Error('Spotify not authenticated. Please check your API settings.');
}
throw new Error(`Failed to load discography: ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const discography = {
albums: data.albums || [],
singles: data.singles || []
};
console.log(`✅ Loaded ${discography.albums.length} albums and ${discography.singles.length} singles`);
// Cache the results
artistsPageState.cache.discography[artistId] = discography;
artistsPageState.artistDiscography = discography;
// Display results
displayArtistDiscography(discography);
// Check completion status for all albums and singles
await checkDiscographyCompletion(artistId, discography);
} catch (error) {
console.error('❌ Failed to load discography:', error);
showDiscographyError(error.message);
}
}
/**
* Display artist's discography in tabs
*/
function displayArtistDiscography(discography) {
console.log(`📀 Displaying discography: ${discography.albums?.length || 0} albums, ${discography.singles?.length || 0} singles`);
// Populate albums
const albumsContainer = document.getElementById('album-cards-container');
if (albumsContainer) {
if (discography.albums?.length > 0) {
albumsContainer.innerHTML = discography.albums.map(album => createAlbumCard(album)).join('');
// Add dynamic glow effects and click handlers to album cards
albumsContainer.querySelectorAll('.album-card').forEach((card, index) => {
const album = discography.albums[index];
if (album.image_url) {
extractImageColors(album.image_url, (colors) => {
applyDynamicGlow(card, colors);
});
}
// Add click handler for download missing tracks modal
card.addEventListener('click', () => handleArtistAlbumClick(album, 'albums'));
card.style.cursor = 'pointer';
});
} else {
albumsContainer.innerHTML = `
<div style="grid-column: 1 / -1; text-align: center; padding: 40px 20px; color: rgba(255, 255, 255, 0.6);">
<div style="font-size: 18px; margin-bottom: 8px;">💿</div>
<div style="font-size: 14px;">No albums found</div>
</div>
`;
}
}
// Populate singles
const singlesContainer = document.getElementById('singles-cards-container');
if (singlesContainer) {
if (discography.singles?.length > 0) {
singlesContainer.innerHTML = discography.singles.map(single => createAlbumCard(single)).join('');
// Add dynamic glow effects and click handlers to singles cards
singlesContainer.querySelectorAll('.album-card').forEach((card, index) => {
const single = discography.singles[index];
if (single.image_url) {
extractImageColors(single.image_url, (colors) => {
applyDynamicGlow(card, colors);
});
}
// Add click handler for download missing tracks modal
card.addEventListener('click', () => handleArtistAlbumClick(single, 'singles'));
card.style.cursor = 'pointer';
});
} else {
singlesContainer.innerHTML = `
<div style="grid-column: 1 / -1; text-align: center; padding: 40px 20px; color: rgba(255, 255, 255, 0.6);">
<div style="font-size: 18px; margin-bottom: 8px;">🎵</div>
<div style="font-size: 14px;">No singles or EPs found</div>
</div>
`;
}
}
}
/**
* Restore cached completion data without re-scanning the database
*/
function restoreCachedCompletionData(artistId) {
console.log(`📦 Restoring cached completion data for artist: ${artistId}`);
const cachedData = artistsPageState.cache.completionData[artistId];
if (!cachedData) {
console.log('⚠️ No cached completion data found, skipping restoration');
return;
}
// Restore album completion overlays
if (cachedData.albums) {
cachedData.albums.forEach(albumCompletion => {
updateAlbumCompletionOverlay(albumCompletion, 'albums');
});
console.log(`✅ Restored ${cachedData.albums.length} album completion overlays`);
}
// Restore singles completion overlays
if (cachedData.singles) {
cachedData.singles.forEach(singleCompletion => {
updateAlbumCompletionOverlay(singleCompletion, 'singles');
});
console.log(`✅ Restored ${cachedData.singles.length} single completion overlays`);
}
}
/**
* Check completion status for entire discography with streaming updates
*/
async function checkDiscographyCompletion(artistId, discography) {
console.log(`🔍 Starting streaming completion check for artist: ${artistId}`);
try {
// Use fetch with streaming response (Server-Sent Events)
// Use fetch with streaming response
const response = await fetch(`/api/artist/${artistId}/completion-stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
discography: discography,
artist_name: artistsPageState.selectedArtist?.name || 'Unknown Artist',
test_mode: window.location.search.includes('test=true')
})
});
if (!response.ok) {
throw new Error(`Failed to start completion check: ${response.status}`);
}
// Handle streaming response
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
handleStreamingCompletionUpdate(data);
} catch (e) {
console.warn('Failed to parse streaming data:', line);
}
}
}
}
} catch (error) {
console.error('❌ Failed to check completion status:', error);
showCompletionError();
}
}
/**
* Handle individual streaming completion updates
*/
function handleStreamingCompletionUpdate(data) {
console.log('🔄 Streaming update received:', data.type, data.name || data.artist_name);
switch (data.type) {
case 'start':
console.log(`🎤 Starting completion check for ${data.artist_name} (${data.total_items} items)`);
// Initialize cache for this artist if not exists
const artistId = artistsPageState.selectedArtist?.id;
if (artistId && !artistsPageState.cache.completionData[artistId]) {
artistsPageState.cache.completionData[artistId] = {
albums: [],
singles: []
};
}
break;
case 'album_completion':
updateAlbumCompletionOverlay(data, 'albums');
// Cache the completion data
cacheCompletionData(data, 'albums');
console.log(`📀 Updated album: ${data.name} (${data.status})`);
break;
case 'single_completion':
updateAlbumCompletionOverlay(data, 'singles');
// Cache the completion data
cacheCompletionData(data, 'singles');
console.log(`🎵 Updated single: ${data.name} (${data.status})`);
break;
case 'error':
console.error('❌ Error processing item:', data.name, data.error);
// Could show error for specific item
break;
case 'complete':
console.log(`✅ Completion check finished (${data.processed_count} items processed)`);
break;
default:
console.log('Unknown streaming update type:', data.type);
}
}
/**
* Cache completion data for future restoration
*/
function cacheCompletionData(completionData, type) {
const artistId = artistsPageState.selectedArtist?.id;
if (!artistId) return;
// Ensure cache structure exists
if (!artistsPageState.cache.completionData[artistId]) {
artistsPageState.cache.completionData[artistId] = {
albums: [],
singles: []
};
}
// Add to appropriate cache array
if (type === 'albums') {
artistsPageState.cache.completionData[artistId].albums.push(completionData);
} else if (type === 'singles') {
artistsPageState.cache.completionData[artistId].singles.push(completionData);
}
}
/**
* Update completion overlay for a specific album/single
*/
function updateAlbumCompletionOverlay(completionData, containerType) {
const containerId = containerType === 'albums' ? 'album-cards-container' : 'singles-cards-container';
const container = document.getElementById(containerId);
if (!container) {
console.warn(`Container ${containerId} not found`);
return;
}
// Find the album card by data-album-id
const albumCard = container.querySelector(`[data-album-id="${completionData.id}"]`);
if (!albumCard) {
console.warn(`Album card not found for ID: ${completionData.id}`);
return;
}
const overlay = albumCard.querySelector('.completion-overlay');
if (!overlay) {
console.warn(`Completion overlay not found for album: ${completionData.name}`);
return;
}
// Remove existing status classes
overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'error');
// Add new status class
overlay.classList.add(completionData.status);
// Update overlay text and content
const statusText = getCompletionStatusText(completionData);
const progressText = `${completionData.owned_tracks}/${completionData.expected_tracks}`;
overlay.innerHTML = `
<span class="completion-status">${statusText}</span>
<span class="completion-progress">${progressText}</span>
`;
// Add tooltip with more details
overlay.title = `${completionData.name}\n${statusText} (${completionData.completion_percentage}%)\nTracks: ${completionData.owned_tracks}/${completionData.expected_tracks}\nConfidence: ${completionData.confidence}`;
// Add brief flash animation to indicate update
overlay.style.animation = 'none';
overlay.offsetHeight; // Trigger reflow
overlay.style.animation = 'completionOverlayFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1)';
console.log(`📊 Updated overlay for "${completionData.name}": ${statusText} (${completionData.completion_percentage}%)`);
}
/**
* Get human-readable status text for completion overlay
*/
function getCompletionStatusText(completionData) {
switch (completionData.status) {
case 'completed':
return 'Complete';
case 'nearly_complete':
return 'Nearly Complete';
case 'partial':
return 'Partial';
case 'missing':
return 'Missing';
case 'error':
return 'Error';
default:
return 'Unknown';
}
}
/**
* Show error state on all completion overlays
*/
function showCompletionError() {
const allOverlays = document.querySelectorAll('.completion-overlay.checking');
allOverlays.forEach(overlay => {
overlay.classList.remove('checking');
overlay.classList.add('error');
overlay.innerHTML = '<span class="completion-status">Error</span>';
overlay.title = 'Failed to check completion status';
});
}
/**
* Create HTML for an album/single card
*/
function createAlbumCard(album) {
const imageUrl = album.image_url || '';
const year = album.release_date ? new Date(album.release_date).getFullYear() : '';
const type = album.album_type === 'album' ? 'Album' :
album.album_type === 'single' ? 'Single' : 'EP';
// Create a fallback gradient if no image is available
const backgroundStyle = imageUrl ?
`background-image: url('${imageUrl}');` :
`background: linear-gradient(135deg, rgba(29, 185, 84, 0.2) 0%, rgba(24, 156, 71, 0.1) 100%);`;
return `
<div class="album-card" data-album-id="${album.id}" data-album-name="${escapeHtml(album.name)}" data-album-type="${album.album_type}" data-total-tracks="${album.total_tracks || 0}">
<div class="album-card-image" style="${backgroundStyle}"></div>
<div class="completion-overlay checking">
<span class="completion-status">Checking...</span>
</div>
<div class="album-card-content">
<div class="album-card-name" title="${escapeHtml(album.name)}">${escapeHtml(album.name)}</div>
<div class="album-card-year">${year || 'Unknown'}</div>
<div class="album-card-type">${type}</div>
</div>
</div>
`;
}
/**
* Initialize artist detail tabs
*/
function initializeArtistTabs() {
const tabButtons = document.querySelectorAll('.artist-tab');
const tabContents = document.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabName = button.getAttribute('data-tab');
// Update button states
tabButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Update content states
tabContents.forEach(content => {
content.classList.remove('active');
if (content.id === `${tabName}-content`) {
content.classList.add('active');
}
});
console.log(`🔄 Switched to ${tabName} tab`);
});
});
}
/**
* State management functions
*/
function showArtistsSearchState() {
console.log('🔄 Showing search state');
const searchState = document.getElementById('artists-search-state');
const resultsState = document.getElementById('artists-results-state');
const detailState = document.getElementById('artist-detail-state');
if (searchState) {
searchState.classList.remove('hidden', 'fade-out');
}
if (resultsState) {
resultsState.classList.add('hidden');
resultsState.classList.remove('show');
}
if (detailState) {
detailState.classList.add('hidden');
detailState.classList.remove('show');
}
artistsPageState.currentView = 'search';
updateArtistsSearchStatus('default');
// Show artist downloads section if there are active downloads
showArtistDownloadsSection();
}
function showArtistsResultsState() {
console.log('🔄 Showing results state');
// Clear artist-specific data when navigating back to results
// This ensures that selecting the same artist again will trigger a fresh scan
if (artistsPageState.selectedArtist) {
const artistId = artistsPageState.selectedArtist.id;
console.log(`🗑️ Clearing cached data for artist: ${artistsPageState.selectedArtist.name}`);
// Clear artist-specific cache data
delete artistsPageState.cache.completionData[artistId];
delete artistsPageState.cache.discography[artistId];
// Clear artist state
artistsPageState.selectedArtist = null;
artistsPageState.artistDiscography = { albums: [], singles: [] };
}
const searchState = document.getElementById('artists-search-state');
const resultsState = document.getElementById('artists-results-state');
const detailState = document.getElementById('artist-detail-state');
if (searchState) {
searchState.classList.add('fade-out');
setTimeout(() => searchState.classList.add('hidden'), 200);
}
if (resultsState) {
resultsState.classList.remove('hidden');
setTimeout(() => resultsState.classList.add('show'), 50);
}
if (detailState) {
detailState.classList.add('hidden');
detailState.classList.remove('show');
}
artistsPageState.currentView = 'results';
}
function showArtistDetailState() {
console.log('🔄 Showing detail state');
const searchState = document.getElementById('artists-search-state');
const resultsState = document.getElementById('artists-results-state');
const detailState = document.getElementById('artist-detail-state');
if (searchState) {
searchState.classList.add('hidden', 'fade-out');
}
if (resultsState) {
resultsState.classList.add('hidden');
resultsState.classList.remove('show');
}
if (detailState) {
detailState.classList.remove('hidden');
setTimeout(() => detailState.classList.add('show'), 50);
}
artistsPageState.currentView = 'detail';
}
/**
* Update search status text and styling
*/
function updateArtistsSearchStatus(status, message = null) {
const statusElement = document.getElementById('artists-search-status');
if (!statusElement) return;
// Clear all status classes
statusElement.classList.remove('searching', 'error');
switch (status) {
case 'default':
statusElement.textContent = 'Start typing to search for artists';
break;
case 'searching':
statusElement.classList.add('searching');
statusElement.textContent = 'Searching for artists...';
break;
case 'error':
statusElement.classList.add('error');
statusElement.innerHTML = `
<div style="margin-bottom: 8px;">${message || 'Search failed. Please try again.'}</div>
<button onclick="retryLastSearch()" style="
background: rgba(29, 185, 84, 0.15);
color: rgba(29, 185, 84, 0.9);
border: 1px solid rgba(29, 185, 84, 0.3);
border-radius: 8px;
padding: 4px 12px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
" onmouseover="this.style.background='rgba(29, 185, 84, 0.25)'"
onmouseout="this.style.background='rgba(29, 185, 84, 0.15)'">
🔄 Retry Search
</button>
`;
break;
}
}
/**
* Retry the last search query
*/
function retryLastSearch() {
const searchInput = document.getElementById('artists-search-input');
const headerSearchInput = document.getElementById('artists-header-search-input');
// Get the last search query from either input
const query = searchInput?.value?.trim() || headerSearchInput?.value?.trim() || artistsPageState.searchQuery;
if (query) {
console.log(`🔄 Retrying search for: "${query}"`);
performArtistsSearch(query);
}
}
/**
* Update artist detail header with artist info
*/
function updateArtistDetailHeader(artist) {
const imageElement = document.getElementById('artist-detail-image');
const nameElement = document.getElementById('artist-detail-name');
const genresElement = document.getElementById('artist-detail-genres');
if (imageElement && artist.image_url) {
imageElement.style.backgroundImage = `url('${artist.image_url}')`;
}
if (nameElement) {
nameElement.textContent = artist.name;
}
if (genresElement) {
const genres = artist.genres?.slice(0, 4).join(' • ') || 'Various genres';
genresElement.textContent = genres;
}
}
/**
* Show loading state for discography
*/
function showDiscographyLoading() {
const albumsContainer = document.getElementById('album-cards-container');
const singlesContainer = document.getElementById('singles-cards-container');
const loadingHtml = `
<div class="album-card loading">
<div class="album-card-image"></div>
<div class="album-card-content">
<div class="album-card-name">Loading...</div>
<div class="album-card-year">-</div>
<div class="album-card-type">-</div>
</div>
</div>
`.repeat(4);
if (albumsContainer) albumsContainer.innerHTML = loadingHtml;
if (singlesContainer) singlesContainer.innerHTML = loadingHtml;
}
/**
* Show error state for discography
*/
function showDiscographyError(message = 'Failed to load discography') {
const albumsContainer = document.getElementById('album-cards-container');
const singlesContainer = document.getElementById('singles-cards-container');
const errorHtml = `
<div style="grid-column: 1 / -1; text-align: center; padding: 40px 20px; color: rgba(255, 65, 54, 0.8);">
<div style="font-size: 18px; margin-bottom: 8px;">⚠️</div>
<div style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">Failed to load discography</div>
<div style="font-size: 12px; color: rgba(255, 65, 54, 0.6); max-width: 300px; margin: 0 auto;">${escapeHtml(message)}</div>
</div>
`;
if (albumsContainer) albumsContainer.innerHTML = errorHtml;
if (singlesContainer) singlesContainer.innerHTML = errorHtml;
}
/**
* Show loading cards while searching
*/
function showSearchLoadingCards() {
const container = document.getElementById('artists-cards-container');
if (!container) return;
const loadingCardHtml = `
<div class="artist-card loading">
<div class="artist-card-background"></div>
<div class="artist-card-overlay"></div>
<div class="artist-card-content">
<div class="artist-card-name">Loading...</div>
<div class="artist-card-genres">Fetching data...</div>
<div class="artist-card-popularity">
<span class="popularity-icon">⏳</span>
<span>Loading...</span>
</div>
</div>
</div>
`;
// Show 6 loading cards
container.innerHTML = loadingCardHtml.repeat(6);
}
// ===============================
// ARTIST ALBUM DOWNLOAD MISSING TRACKS INTEGRATION
// ===============================
/**
* Handle album/single/EP click to open download missing tracks modal
*/
async function handleArtistAlbumClick(album, albumType) {
console.log(`🎵 Album clicked: ${album.name} (${album.album_type}) from artist: ${artistsPageState.selectedArtist?.name}`);
if (!artistsPageState.selectedArtist) {
console.error('❌ No selected artist found');
showToast('Error: No artist selected', 'error');
return;
}
try {
// Create virtual playlist ID
const virtualPlaylistId = `artist_album_${artistsPageState.selectedArtist.id}_${album.id}`;
// Check if modal already exists and show it
if (activeDownloadProcesses[virtualPlaylistId]) {
console.log(`📱 Reopening existing modal for ${album.name}`);
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;
}
}
// Create virtual playlist and open modal
await createArtistAlbumVirtualPlaylist(album, albumType);
} catch (error) {
console.error('❌ Error opening download missing tracks modal:', error);
showToast(`Error opening download modal: ${error.message}`, 'error');
}
}
/**
* Create virtual playlist for artist album and open download missing tracks modal
*/
async function createArtistAlbumVirtualPlaylist(album, albumType) {
const artist = artistsPageState.selectedArtist;
const virtualPlaylistId = `artist_album_${artist.id}_${album.id}`;
console.log(`🎵 Creating virtual playlist for: ${artist.name} - ${album.name}`);
try {
// Show loading toast
showToast(`Loading tracks for ${album.name}...`, 'info');
// Fetch album tracks from backend
const response = await fetch(`/api/artist/${artist.id}/album/${album.id}/tracks`);
if (!response.ok) {
if (response.status === 401) {
throw new Error('Spotify not authenticated. Please check your API settings.');
}
throw new Error(`Failed to load album tracks: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found for this album');
}
console.log(`✅ Loaded ${data.tracks.length} tracks for ${album.name}`);
// Format playlist name with artist and album info
const playlistName = `[${artist.name}] ${album.name}`;
// Open download missing tracks modal with formatted tracks
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, data.tracks, album, artist);
// Track this download for artist bubble management
registerArtistDownload(artist, album, virtualPlaylistId, albumType);
} catch (error) {
console.error('❌ Error creating virtual playlist:', error);
showToast(`Failed to load album: ${error.message}`, 'error');
throw error;
}
}
/**
* Open download missing tracks modal specifically for artist albums
* Similar to openDownloadMissingModalForYouTube but for artist albums
*/
async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist) {
// 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 artist album: ${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 other modals
activeDownloadProcesses[virtualPlaylistId] = {
status: 'idle',
modalElement: modal,
poller: null,
batchId: null,
playlist: virtualPlaylist,
tracks: spotifyTracks,
// Additional metadata for artist albums
artist: artist,
album: album,
albumType: album.album_type
};
// Use the exact same modal HTML structure as the existing modals
modal.innerHTML = `
<div class="download-missing-modal-content">
<div class="download-missing-modal-header">
<h2 class="download-missing-modal-title">Download Missing Tracks - ${escapeHtml(playlistName)}</h2>
<span class="download-missing-modal-close" onclick="closeDownloadMissingModal('${virtualPlaylistId}')">&times;</span>
</div>
<div class="download-missing-modal-body">
<div class="download-dashboard-stats">
<div class="dashboard-stat stat-total">
<div class="dashboard-stat-number" id="stat-total-${virtualPlaylistId}">${spotifyTracks.length}</div>
<div class="dashboard-stat-label">Total Tracks</div>
</div>
<div class="dashboard-stat stat-found">
<div class="dashboard-stat-number" id="stat-found-${virtualPlaylistId}">-</div>
<div class="dashboard-stat-label">Found in Library</div>
</div>
<div class="dashboard-stat stat-missing">
<div class="dashboard-stat-number" id="stat-missing-${virtualPlaylistId}">-</div>
<div class="dashboard-stat-label">Missing Tracks</div>
</div>
<div class="dashboard-stat stat-downloaded">
<div class="dashboard-stat-number" id="stat-downloaded-${virtualPlaylistId}">0</div>
<div class="dashboard-stat-label">Downloaded</div>
</div>
</div>
<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>
</div>
<div class="download-tracks-table-container">
<table class="download-tracks-table">
<thead>
<tr>
<th>#</th>
<th>Track Name</th>
<th>Artist(s)</th>
<th>Duration</th>
<th>Library Status</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-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(track.artists.join(', '))}">${track.artists.join(', ')}</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">
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
Begin Analysis
</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>
`;
modal.style.display = 'flex';
console.log(`✅ Successfully opened download missing tracks modal for: ${playlistName}`);
}
// ===============================
// ARTIST DOWNLOADS MANAGEMENT SYSTEM
// ===============================
/**
* Register a new artist download for bubble management
*/
function registerArtistDownload(artist, album, virtualPlaylistId, albumType) {
console.log(`📝 Registering artist download: ${artist.name} - ${album.name}`);
const artistId = artist.id;
// Initialize artist bubble if it doesn't exist
if (!artistDownloadBubbles[artistId]) {
artistDownloadBubbles[artistId] = {
artist: artist,
downloads: [],
element: null,
hasCompletedDownloads: false
};
}
// Add this download to the artist's downloads
const downloadInfo = {
virtualPlaylistId: virtualPlaylistId,
album: album,
albumType: albumType,
status: 'in_progress', // 'in_progress', 'completed', 'view_results'
startTime: new Date()
};
artistDownloadBubbles[artistId].downloads.push(downloadInfo);
// Show/update the artist downloads section
updateArtistDownloadsSection();
// Save snapshot of current state
saveArtistBubbleSnapshot();
// Monitor this download for completion
monitorArtistDownload(artistId, virtualPlaylistId);
}
/**
* Debounced update for artist downloads section to prevent rapid updates
*/
function updateArtistDownloadsSection() {
if (downloadsUpdateTimeout) {
clearTimeout(downloadsUpdateTimeout);
}
downloadsUpdateTimeout = setTimeout(() => {
showArtistDownloadsSection();
}, 300); // 300ms debounce
}
// --- Artist Bubble Snapshot System ---
let snapshotSaveTimeout = null; // Debounce snapshot saves
async function saveArtistBubbleSnapshot() {
/**
* Saves current artistDownloadBubbles state to backend for persistence.
* Debounced to prevent excessive backend calls.
*/
// Clear any existing timeout
if (snapshotSaveTimeout) {
clearTimeout(snapshotSaveTimeout);
}
// Debounce the actual save
snapshotSaveTimeout = setTimeout(async () => {
try {
const bubbleCount = Object.keys(artistDownloadBubbles).length;
// Don't save empty state
if (bubbleCount === 0) {
console.log('📸 Skipping snapshot save - no artist bubbles to save');
return;
}
console.log(`📸 Saving artist bubble snapshot: ${bubbleCount} artists`);
// Prepare snapshot data (clean up DOM references)
const cleanBubbles = {};
for (const [artistId, bubbleData] of Object.entries(artistDownloadBubbles)) {
cleanBubbles[artistId] = {
artist: bubbleData.artist,
downloads: bubbleData.downloads.map(download => ({
virtualPlaylistId: download.virtualPlaylistId,
album: download.album,
albumType: download.albumType,
status: download.status,
startTime: download.startTime instanceof Date ? download.startTime.toISOString() : download.startTime
})),
hasCompletedDownloads: bubbleData.hasCompletedDownloads
};
}
const response = await fetch('/api/artist_bubbles/snapshot', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
bubbles: cleanBubbles
})
});
const data = await response.json();
if (data.success) {
console.log(`✅ Artist bubble snapshot saved: ${bubbleCount} artists`);
} else {
console.error('❌ Failed to save artist bubble snapshot:', data.error);
}
} catch (error) {
console.error('❌ Error saving artist bubble snapshot:', error);
}
}, 1000); // 1 second debounce
}
async function hydrateArtistBubblesFromSnapshot() {
/**
* Hydrates artist download bubbles from backend snapshot with live status.
* Called on page load to restore bubble state.
*/
try {
console.log('🔄 Loading artist bubble snapshot from backend...');
const response = await fetch('/api/artist_bubbles/hydrate');
const data = await response.json();
if (!data.success) {
console.error('❌ Failed to load artist bubble snapshot:', data.error);
return;
}
const bubbles = data.bubbles || {};
const stats = data.stats || {};
console.log(`🔄 Loaded bubble snapshot: ${stats.total_artists || 0} artists, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`);
if (Object.keys(bubbles).length === 0) {
console.log(' No artist bubbles to hydrate');
return;
}
// Clear existing state
artistDownloadBubbles = {};
// Restore artistDownloadBubbles with hydrated data
for (const [artistId, bubbleData] of Object.entries(bubbles)) {
artistDownloadBubbles[artistId] = {
artist: bubbleData.artist,
downloads: bubbleData.downloads.map(download => ({
virtualPlaylistId: download.virtualPlaylistId,
album: download.album,
albumType: download.albumType,
status: download.status, // Live status from backend
startTime: new Date(download.startTime)
})),
element: null, // Will be created when UI updates
hasCompletedDownloads: bubbleData.hasCompletedDownloads
};
console.log(`🔄 Hydrated artist: ${bubbleData.artist.name} (${bubbleData.downloads.length} downloads)`);
// Start monitoring for any in-progress downloads
for (const download of bubbleData.downloads) {
if (download.status === 'in_progress') {
console.log(`📡 Starting monitoring for: ${download.album.name}`);
monitorArtistDownload(artistId, download.virtualPlaylistId);
}
}
}
// Update UI to show hydrated bubbles
updateArtistDownloadsSection();
const totalArtists = Object.keys(artistDownloadBubbles).length;
console.log(`✅ Successfully hydrated ${totalArtists} artist download bubbles`);
} catch (error) {
console.error('❌ Error hydrating artist bubbles from snapshot:', error);
}
}
/**
* Show or update the artist downloads section in search state
*/
function showArtistDownloadsSection() {
console.log(`🔄 [SHOW] showArtistDownloadsSection() called - refreshing artist bubbles`);
console.log(`🔄 [SHOW] Current view: ${artistsPageState.currentView}, artistDownloadBubbles count: ${Object.keys(artistDownloadBubbles).length}`);
// Only show in search state
if (artistsPageState.currentView !== 'search') {
console.log(`⏭️ [SHOW] Skipping - not in search state (current: ${artistsPageState.currentView})`);
return;
}
const artistsSearchState = document.getElementById('artists-search-state');
if (!artistsSearchState) {
console.log(`⏭️ [SHOW] Skipping - no artists-search-state element found`);
return;
}
let downloadsSection = document.getElementById('artist-downloads-section');
// Create section if it doesn't exist
if (!downloadsSection) {
downloadsSection = document.createElement('div');
downloadsSection.id = 'artist-downloads-section';
downloadsSection.className = 'artist-downloads-section';
// Insert after the search container
const searchContainer = artistsSearchState.querySelector('.artists-search-container');
if (searchContainer) {
searchContainer.insertAdjacentElement('afterend', downloadsSection);
}
}
// Count active artists (those with downloads)
const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId =>
artistDownloadBubbles[artistId].downloads.length > 0
);
if (activeArtists.length === 0) {
downloadsSection.style.display = 'none';
return;
}
// Show and populate the section
downloadsSection.style.display = 'block';
downloadsSection.innerHTML = `
<div class="artist-downloads-header">
<h3 class="artist-downloads-title">Current Downloads</h3>
<p class="artist-downloads-subtitle">Active download processes</p>
</div>
<div class="artist-bubble-container" id="artist-bubble-container">
${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')}
</div>
`;
// Add event listeners to bubble cards
activeArtists.forEach(artistId => {
const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`);
if (bubbleCard) {
bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId));
// Add dynamic glow effect
const artist = artistDownloadBubbles[artistId].artist;
if (artist.image_url) {
extractImageColors(artist.image_url, (colors) => {
applyDynamicGlow(bubbleCard, colors);
});
}
}
});
}
/**
* Create HTML for an artist bubble card
*/
function createArtistBubbleCard(artistBubbleData) {
const { artist, downloads } = artistBubbleData;
const activeCount = downloads.filter(d => d.status === 'in_progress').length;
const completedCount = downloads.filter(d => d.status === 'view_results').length;
const allCompleted = activeCount === 0 && completedCount > 0;
// Enhanced debug logging for bubble card creation and green checkmark detection
console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, {
totalDownloads: downloads.length,
activeCount,
completedCount,
allCompleted,
downloadStatuses: downloads.map(d => `${d.album.name}: ${d.status}`)
});
// CRITICAL: Green checkmark detection logging
if (allCompleted) {
console.log(`🟢 [BUBBLE] GREEN CHECKMARK DETECTED for ${artist.name} - all ${downloads.length} downloads completed`);
console.log(`✅ [BUBBLE] This bubble will have 'all-completed' class and green checkmark`);
} else if (activeCount === 0 && completedCount === 0) {
console.log(`⭕ [BUBBLE] No active or completed downloads for ${artist.name} - this shouldn't happen`);
} else {
console.log(`⏳ [BUBBLE] Still waiting for completion: ${activeCount} active, ${completedCount} completed`);
}
const imageUrl = artist.image_url || '';
const backgroundStyle = imageUrl ?
`background-image: url('${imageUrl}');` :
`background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
return `
<div class="artist-bubble-card ${allCompleted ? 'all-completed' : ''}"
data-artist-id="${artist.id}"
title="Click to manage downloads for ${escapeHtml(artist.name)}">
<div class="artist-bubble-image" style="${backgroundStyle}"></div>
<div class="artist-bubble-overlay"></div>
<div class="artist-bubble-content">
<div class="artist-bubble-name">${escapeHtml(artist.name)}</div>
<div class="artist-bubble-status">
${activeCount > 0 ? `${activeCount} active` : ''}
${completedCount > 0 ? `${completedCount} completed` : ''}
</div>
</div>
${allCompleted ? `
<div class="bulk-complete-indicator"
onclick="event.stopPropagation(); bulkCompleteArtistDownloads('${artist.id}')"
title="Complete all downloads">
<span class="bulk-complete-icon">✅</span>
</div>
` : ''}
</div>
`;
}
/**
* Monitor an artist download for completion status changes
*/
function monitorArtistDownload(artistId, virtualPlaylistId) {
// Check if the download process exists and monitor its status
const checkStatus = () => {
const process = activeDownloadProcesses[virtualPlaylistId];
if (!process || !artistDownloadBubbles[artistId]) {
return; // Process or artist bubble no longer exists
}
// Find this download in the artist's downloads
const download = artistDownloadBubbles[artistId].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId);
if (!download) return;
// Update download status based on process status
if (process.status === 'complete' && download.status === 'in_progress') {
download.status = 'view_results';
console.log(`✅ Download completed for ${artistDownloadBubbles[artistId].artist.name} - ${download.album.name}`);
console.log(`📊 Artist ${artistId} downloads status:`, artistDownloadBubbles[artistId].downloads.map(d => `${d.album.name}: ${d.status}`));
// Update the downloads section
updateArtistDownloadsSection();
// Save snapshot of updated state
saveArtistBubbleSnapshot();
// Check if all downloads for this artist are now completed
const artistDownloads = artistDownloadBubbles[artistId].downloads;
const allCompleted = artistDownloads.every(d => d.status === 'view_results');
if (allCompleted) {
console.log(`🟢 All downloads completed for ${artistDownloadBubbles[artistId].artist.name} - green checkmark should appear`);
console.log(`🎯 [STATUS DEBUG] Green checkmark trigger - forcing bubble refresh`);
// Force immediate bubble refresh to show green checkmark
setTimeout(updateArtistDownloadsSection, 100);
}
}
// Continue monitoring if still active
if (process.status !== 'complete') {
setTimeout(checkStatus, 2000); // Check every 2 seconds
}
};
// Start monitoring after a brief delay
setTimeout(checkStatus, 1000);
}
/**
* Open the artist download management modal
*/
function openArtistDownloadModal(artistId) {
const artistBubbleData = artistDownloadBubbles[artistId];
if (!artistBubbleData || artistDownloadModalOpen) return;
console.log(`🎵 [MODAL OPEN] Opening artist download modal for: ${artistBubbleData.artist.name}`);
console.log(`📊 [MODAL OPEN] Current download statuses:`, artistBubbleData.downloads.map(d => `${d.album.name}: ${d.status}`));
artistDownloadModalOpen = true;
const modal = document.createElement('div');
modal.id = 'artist-download-management-modal';
modal.className = 'artist-download-management-modal';
modal.innerHTML = `
<div class="artist-download-modal-content">
<div class="artist-download-modal-header">
<h2 class="artist-download-modal-title">Downloads for ${escapeHtml(artistBubbleData.artist.name)}</h2>
<span class="artist-download-modal-close" onclick="closeArtistDownloadModal()">&times;</span>
</div>
<div class="artist-download-modal-body">
<div class="artist-download-items" id="artist-download-items-${artistId}">
${artistBubbleData.downloads.map((download, index) => createArtistDownloadItem(download, index)).join('')}
</div>
</div>
</div>
<div class="artist-download-modal-overlay" onclick="closeArtistDownloadModal()"></div>
`;
document.body.appendChild(modal);
modal.style.display = 'flex';
// Monitor for real-time updates
startArtistDownloadModalMonitoring(artistId);
}
/**
* Create HTML for an individual download item in the artist modal
*/
function createArtistDownloadItem(download, index) {
const { album, albumType, status, virtualPlaylistId } = download;
const buttonText = status === 'view_results' ? 'View Results' : 'View Progress';
const buttonClass = status === 'view_results' ? 'completed' : 'active';
// Enhanced debugging for button text generation
console.log(`🎯 [BUTTON] Creating item for ${album.name}: status='${status}' → buttonText='${buttonText}'`);
return `
<div class="artist-download-item" data-playlist-id="${virtualPlaylistId}">
<div class="download-item-info">
<div class="download-item-name">${escapeHtml(album.name)}</div>
<div class="download-item-type">${albumType === 'album' ? 'Album' : albumType === 'single' ? 'Single' : 'EP'}</div>
</div>
<div class="download-item-actions">
<button class="download-item-btn ${buttonClass}"
onclick="openArtistDownloadProcess('${virtualPlaylistId}')">
${buttonText}
</button>
</div>
</div>
`;
}
/**
* Monitor artist download modal for real-time updates
*/
function startArtistDownloadModalMonitoring(artistId) {
if (!artistDownloadModalOpen) return;
const updateModal = () => {
const modal = document.getElementById('artist-download-management-modal');
const itemsContainer = document.getElementById(`artist-download-items-${artistId}`);
if (!modal || !itemsContainer || !artistDownloadBubbles[artistId]) return;
// Check for completed downloads that need to be removed
const activeDownloads = artistDownloadBubbles[artistId].downloads.filter(download => {
const process = activeDownloadProcesses[download.virtualPlaylistId];
// Keep if process exists or if it's completed but not yet cleaned up
return process !== undefined;
});
// Update the downloads array
artistDownloadBubbles[artistId].downloads = activeDownloads;
// If no downloads left, close modal
if (activeDownloads.length === 0) {
closeArtistDownloadModal();
return;
}
// Update modal content and synchronize with bubble state
let statusChanged = false;
itemsContainer.innerHTML = activeDownloads.map((download, index) => {
const process = activeDownloadProcesses[download.virtualPlaylistId];
if (process) {
const newStatus = process.status === 'complete' ? 'view_results' : 'in_progress';
if (download.status !== newStatus) {
console.log(`🔄 [ARTIST MODAL] Updating ${download.album.name} status from ${download.status} to ${newStatus}`);
download.status = newStatus;
statusChanged = true;
}
}
return createArtistDownloadItem(download, index);
}).join('');
// CRITICAL: If any status changed, immediately refresh artist bubble to show green checkmarks
if (statusChanged) {
console.log(`🎯 [SYNC] Status change detected in artist modal - refreshing bubble display`);
updateArtistDownloadsSection();
// Check if all downloads for this artist are now completed
const artistDownloads = artistDownloadBubbles[artistId].downloads;
const allCompleted = artistDownloads.every(d => d.status === 'view_results');
if (allCompleted) {
console.log(`🟢 [ARTIST MODAL] All downloads completed for artist ${artistId} - triggering green checkmark`);
// Force additional refresh after a brief delay to ensure UI updates
setTimeout(() => {
console.log(`✨ [ARTIST MODAL] Forcing final refresh for green checkmark`);
updateArtistDownloadsSection();
}, 200);
}
}
// Continue monitoring
setTimeout(updateModal, 2000);
};
setTimeout(updateModal, 1000);
}
/**
* Open a specific artist download process modal
*/
function openArtistDownloadProcess(virtualPlaylistId) {
const process = activeDownloadProcesses[virtualPlaylistId];
if (process && process.modalElement) {
// Close artist management modal first
closeArtistDownloadModal();
// Show the download process modal
process.modalElement.style.display = 'flex';
if (process.status === 'complete') {
showToast('Review download results and click "Close" to finish.', 'info');
}
}
}
/**
* Close the artist download management modal
*/
function closeArtistDownloadModal() {
const modal = document.getElementById('artist-download-management-modal');
if (modal) {
modal.remove();
}
artistDownloadModalOpen = false;
}
/**
* Bulk complete all downloads for an artist (when all are in 'view_results' state)
*/
function bulkCompleteArtistDownloads(artistId) {
console.log(`🎯 Bulk completing downloads for artist: ${artistId}`);
const artistBubbleData = artistDownloadBubbles[artistId];
if (!artistBubbleData) {
console.warn(`❌ No artist bubble data found for ${artistId}`);
return;
}
// Find all downloads in 'view_results' state
const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results');
console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`,
completedDownloads.map(d => d.album.name));
if (completedDownloads.length === 0) {
console.warn(`⚠️ No completed downloads found for bulk close`);
showToast('No completed downloads to close', 'info');
return;
}
// Programmatically close all completed modals
completedDownloads.forEach(download => {
const process = activeDownloadProcesses[download.virtualPlaylistId];
if (process && process.modalElement) {
console.log(`🗑️ Closing modal for: ${download.album.name}`);
// Trigger the close function which handles cleanup
closeDownloadMissingModal(download.virtualPlaylistId);
} else {
console.warn(`⚠️ No active process or modal found for: ${download.album.name}`);
}
});
showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success');
}
/**
* Clean up artist download when a modal is closed
*/
function cleanupArtistDownload(virtualPlaylistId) {
console.log(`🔍 [CLEANUP] Looking for download to cleanup: ${virtualPlaylistId}`);
console.log(`🔍 [CLEANUP] Current artist bubbles:`, Object.keys(artistDownloadBubbles));
// Find which artist this download belongs to
for (const artistId in artistDownloadBubbles) {
const downloads = artistDownloadBubbles[artistId].downloads;
const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId);
console.log(`🔍 [CLEANUP] Checking artist ${artistId}: ${downloads.length} downloads`);
downloads.forEach(d => console.log(` - ${d.album.name} (${d.virtualPlaylistId}): ${d.status}`));
if (downloadIndex !== -1) {
const downloadToRemove = downloads[downloadIndex];
console.log(`🧹 [CLEANUP] Found download to cleanup: ${downloadToRemove.album.name} (status: ${downloadToRemove.status})`);
// Remove this download from the artist's downloads
downloads.splice(downloadIndex, 1);
console.log(`✅ [CLEANUP] Removed download from artist ${artistId}. Remaining: ${downloads.length}`);
// If no more downloads for this artist, remove the bubble
if (downloads.length === 0) {
delete artistDownloadBubbles[artistId];
console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistId}`);
} else {
console.log(`📊 [CLEANUP] Artist ${artistId} still has ${downloads.length} downloads remaining`);
}
// Update the downloads section
console.log(`🔄 [CLEANUP] Updating artist downloads section...`);
updateArtistDownloadsSection();
// Save snapshot of updated state
saveArtistBubbleSnapshot();
break;
}
}
console.log(`✅ [CLEANUP] Cleanup process completed for ${virtualPlaylistId}`);
}
/**
* Force refresh all artist download statuses (useful for debugging)
*/
function refreshAllArtistDownloadStatuses() {
console.log('🔄 Force refreshing all artist download statuses...');
for (const artistId in artistDownloadBubbles) {
const artistData = artistDownloadBubbles[artistId];
let hasChanges = false;
artistData.downloads.forEach(download => {
const process = activeDownloadProcesses[download.virtualPlaylistId];
if (process) {
const expectedStatus = process.status === 'complete' ? 'view_results' : 'in_progress';
if (download.status !== expectedStatus) {
console.log(`🔧 Fixing status for ${download.album.name}: ${download.status}${expectedStatus}`);
download.status = expectedStatus;
hasChanges = true;
}
}
});
if (hasChanges) {
console.log(`✅ Updated statuses for ${artistData.artist.name}`);
}
}
// Force update the downloads section
showArtistDownloadsSection();
}
/**
* Extract dominant colors from an image for dynamic glow effects
*/
async function extractImageColors(imageUrl, callback) {
if (!imageUrl) {
callback(['#1db954', '#1ed760']); // Fallback to Spotify green
return;
}
// Check cache first for performance
if (artistsPageState.cache.colors[imageUrl]) {
callback(artistsPageState.cache.colors[imageUrl]);
return;
}
try {
// Create a canvas to analyze the image
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function() {
// Resize to small dimensions for faster processing
const size = 50;
canvas.width = size;
canvas.height = size;
// Draw image to canvas
ctx.drawImage(img, 0, 0, size, size);
try {
// Get image data
const imageData = ctx.getImageData(0, 0, size, size);
const data = imageData.data;
// Extract colors (sample every few pixels for performance)
const colors = [];
for (let i = 0; i < data.length; i += 16) { // Sample every 4th pixel
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const alpha = data[i + 3];
// Skip transparent or very dark pixels
if (alpha > 128 && (r + g + b) > 150) {
colors.push({ r, g, b });
}
}
if (colors.length === 0) {
callback(['#1db954', '#1ed760']); // Fallback
return;
}
// Find dominant colors using a simple clustering approach
const dominantColors = findDominantColors(colors, 2);
// Convert to CSS hex colors
const hexColors = dominantColors.map(color =>
`#${((1 << 24) + (color.r << 16) + (color.g << 8) + color.b).toString(16).slice(1)}`
);
// Cache the colors for future use
artistsPageState.cache.colors[imageUrl] = hexColors;
callback(hexColors);
} catch (e) {
console.warn('Color extraction failed, using fallback colors:', e);
callback(['#1db954', '#1ed760']);
}
};
img.onerror = function() {
callback(['#1db954', '#1ed760']); // Fallback on error
};
img.src = imageUrl;
} catch (error) {
console.warn('Image color extraction error:', error);
callback(['#1db954', '#1ed760']);
}
}
/**
* Simple color clustering to find dominant colors
*/
function findDominantColors(colors, numColors = 2) {
if (colors.length === 0) return [{ r: 29, g: 185, b: 84 }];
// Simple k-means clustering
let centroids = [];
// Initialize centroids randomly
for (let i = 0; i < numColors; i++) {
centroids.push(colors[Math.floor(Math.random() * colors.length)]);
}
// Run a few iterations of k-means
for (let iteration = 0; iteration < 5; iteration++) {
const clusters = Array(numColors).fill().map(() => []);
// Assign each color to nearest centroid
colors.forEach(color => {
let minDistance = Infinity;
let nearestCluster = 0;
centroids.forEach((centroid, i) => {
const distance = Math.sqrt(
Math.pow(color.r - centroid.r, 2) +
Math.pow(color.g - centroid.g, 2) +
Math.pow(color.b - centroid.b, 2)
);
if (distance < minDistance) {
minDistance = distance;
nearestCluster = i;
}
});
clusters[nearestCluster].push(color);
});
// Update centroids
centroids = clusters.map(cluster => {
if (cluster.length === 0) return centroids[0]; // Fallback
const avgR = cluster.reduce((sum, c) => sum + c.r, 0) / cluster.length;
const avgG = cluster.reduce((sum, c) => sum + c.g, 0) / cluster.length;
const avgB = cluster.reduce((sum, c) => sum + c.b, 0) / cluster.length;
return { r: Math.round(avgR), g: Math.round(avgG), b: Math.round(avgB) };
});
}
// Ensure we have vibrant colors by boosting saturation
return centroids.map(color => {
const max = Math.max(color.r, color.g, color.b);
const min = Math.min(color.r, color.g, color.b);
const saturation = max === 0 ? 0 : (max - min) / max;
// Boost low saturation colors
if (saturation < 0.4) {
const factor = 1.3;
return {
r: Math.min(255, Math.round(color.r * factor)),
g: Math.min(255, Math.round(color.g * factor)),
b: Math.min(255, Math.round(color.b * factor))
};
}
return color;
});
}
/**
* Apply dynamic glow effect to a card element
*/
function applyDynamicGlow(cardElement, colors) {
if (!cardElement || colors.length < 2) return;
const color1 = colors[0];
const color2 = colors[1];
// Add a small delay to make the effect feel more natural
setTimeout(() => {
// Create CSS custom properties for the dynamic colors
cardElement.style.setProperty('--glow-color-1', color1);
cardElement.style.setProperty('--glow-color-2', color2);
cardElement.classList.add('has-dynamic-glow');
console.log(`🎨 Applied dynamic glow: ${color1}, ${color2}`);
}, Math.random() * 200 + 100); // Random delay between 100-300ms
}
/**
* Utility function to escape HTML
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// --- Global Cleanup on Page Unload ---
// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed