soulsync/webui/static/script.js
2025-09-02 23:13:41 -07:00

8748 lines
No EOL
352 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;
// --- 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
// 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':
stopDownloadPolling();
await loadDashboardData();
break;
case 'sync':
stopDownloadPolling();
initializeSyncPage();
await loadSyncData();
break;
case 'downloads':
initializeSearch();
initializeFilters();
await loadDownloadsData();
break;
case 'artists':
stopDownloadPolling();
await loadArtistsData();
break;
case 'settings':
initializeSettings();
stopDownloadPolling();
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 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 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 wishlist processes specially
if (playlist_id === "wishlist") {
console.log(`🔍 Current activeDownloadProcesses keys: [${Object.keys(activeDownloadProcesses).join(', ')}]`);
await openDownloadMissingWishlistModal();
const process = activeDownloadProcesses[playlist_id];
if (!process) {
console.error('❌ Failed to create wishlist process in activeDownloadProcesses');
return;
}
console.log(`✅ Setting wishlist process state - batchId: ${batch_id}, status: running`);
process.status = 'running';
process.batchId = batch_id;
// Note: Wishlist processes don't affect sync page refresh button 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';
startModalDownloadPolling(playlist_id);
// Enhanced logic: Show modal if user-requested OR if it was previously visible before page refresh
const wasModalPreviouslyVisible = WishlistModalState.wasVisible();
const shouldShowModal = userRequested || (wasModalPreviouslyVisible && !WishlistModalState.wasUserClosed());
if (shouldShowModal) {
process.modalElement.style.display = 'flex';
WishlistModalState.setVisible(); // Track that modal is now visible
WishlistModalState.clearUserClosed(); // Clear any user closed state since we're showing it
console.log(`🔍 Showing wishlist modal - User requested: ${userRequested}, Previously visible: ${wasModalPreviouslyVisible}`);
} else {
process.modalElement.style.display = 'none';
WishlistModalState.setHidden(); // Track that modal is hidden
console.log('🔍 Hiding wishlist modal for background rehydration');
}
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}`);
// --- THIS IS THE FIX ---
// 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 })
});
} catch (error) {
console.error('Failed to send cleanup request to server:', error);
}
}
// --- END OF FIX ---
// Stop client-side polling
if (process.poller) {
clearInterval(process.poller);
}
// 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];
// 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';
}
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');
}
// Reset Tidal playlist phase to 'discovered' when modal is closed after completion
if (playlistId.startsWith('tidal_')) {
const tidalPlaylistId = playlistId.replace('tidal_', '');
updateTidalCardPhase(tidalPlaylistId, 'discovered');
}
// 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');
}
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'}`;
}
}
}
function startModalDownloadPolling(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process || !process.batchId) return;
if (process.poller) clearInterval(process.poller);
process.poller = setInterval(async () => {
if (!activeDownloadProcesses[playlistId]) {
clearInterval(process.poller);
return;
}
try {
const response = await fetch(`/api/playlists/${process.batchId}/download_status`);
const data = await response.json();
if (data.error) throw new Error(data.error);
// Auto-show wishlist modal during active auto-processing
const isWishlist = (playlistId === 'wishlist');
const isAutoInitiated = data.auto_initiated || false;
const isModalHidden = process.modalElement && process.modalElement.style.display === 'none';
if (isWishlist && isAutoInitiated && isModalHidden && currentPage === 'dashboard' && !WishlistModalState.wasUserClosed()) {
console.log('🤖 [Polling] Auto-showing wishlist modal during active auto-processing');
process.modalElement.style.display = 'flex';
WishlistModalState.setVisible();
}
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') {
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;
(data.tasks || []).forEach(task => {
const row = document.querySelector(`#download-missing-modal-${playlistId} tr[data-track-index="${task.track_index}"]`);
if (!row) return;
// Stronger protection: Don't override locally cancelled tracks with any backend updates
if (row.dataset.locallyCancelled === 'true') {
failedOrCancelledCount++;
return; // Completely skip processing this task to avoid any UI conflicts
}
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 '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 && !['completed', 'failed', 'cancelled'].includes(task.status) && actionsEl.innerHTML === '-') {
actionsEl.innerHTML = `<button class="cancel-track-btn" title="Cancel this download" onclick="cancelTrackDownload('${playlistId}', ${task.track_index})">×</button>`;
}
if (actionsEl && ['completed', 'failed', 'cancelled'].includes(task.status)) {
actionsEl.innerHTML = '-';
}
});
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;
if (data.phase === 'complete' || data.phase === 'error' || (missingCount > 0 && totalFinished >= missingCount)) {
// 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);
// Auto-show modal for wishlist auto-processing if user is on dashboard and hasn't closed it
if (isWishlist && isAutoInitiated && isModalHidden && currentPage === 'dashboard' && !WishlistModalState.wasUserClosed()) {
console.log('🤖 [Polling] Auto-showing wishlist modal for live updates during auto-processing');
process.modalElement.style.display = 'flex';
WishlistModalState.setVisible();
showToast('Auto-processing wishlist - showing live updates', 'info', 2000);
}
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';
updateTidalCardPhase(tidalPlaylistId, 'download_complete');
console.log(`✅ 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`);
// Clean up polling first
clearInterval(process.poller);
// 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';
clearInterval(process.poller);
process.poller = null;
updatePlaylistCardUI(playlistId);
}
}
} catch (error) {
console.error(`Polling error for ${playlistId}:`, error);
}
}, 500);
}
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 = [];
}
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');
}
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
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.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;
window.handleViewProgressClick = handleViewProgressClick;
// Wishlist Modal functions
window.openDownloadMissingWishlistModal = openDownloadMissingWishlistModal;
window.startWishlistMissingTracksProcess = startWishlistMissingTracksProcess;
window.handleWishlistButtonClick = handleWishlistButtonClick;
// Helper functions
window.escapeHtml = escapeHtml;
window.formatArtists = formatArtists;
// 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');
// Check if we need to show the modal (no existing modal or modal is hidden)
const needsModalDisplay = !clientWishlistProcess ||
!clientWishlistProcess.modalElement ||
clientWishlistProcess.modalElement.style.display === 'none' ||
!document.body.contains(clientWishlistProcess.modalElement);
if (needsModalDisplay) {
console.log('🤖 [Auto-Processing] Auto-showing wishlist modal for auto-processing');
// Rehydrate or create the modal
if (clientWishlistProcess && clientWishlistProcess.modalElement && document.body.contains(clientWishlistProcess.modalElement)) {
// Just show existing modal
clientWishlistProcess.modalElement.style.display = 'flex';
WishlistModalState.setVisible();
} else {
// Rehydrate the modal with userRequested=false but show it due to auto-processing
await rehydrateModal(serverWishlistProcess, false); // Don't mark as user-requested
// But then force show it since it's auto-initiated and should be visible
if (activeDownloadProcesses[playlistId] && activeDownloadProcesses[playlistId].modalElement) {
activeDownloadProcesses[playlistId].modalElement.style.display = 'flex';
WishlistModalState.setVisible();
console.log('🤖 [Auto-Processing] Forced display of rehydrated modal for auto-processing');
}
}
// Show a toast to inform user about auto-processing
showToast('Auto-processing wishlist tracks...', 'info', 3000);
}
}
} 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) {
const state = tidalPlaylistStates[playlistId];
if (!state) return;
console.log(`🎵 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 (like sync.py)
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(`🔍 Opening download modal for Tidal playlist: ${state.playlist.name}`);
// Check if modal already exists, if not create it
if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) {
const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId];
if (process.modalElement) {
process.modalElement.style.display = 'flex';
}
} else {
// Need to create the download modal - fetch the discovery results
await rehydrateTidalDownloadModal(playlistId, state);
}
} else {
console.error('❌ No converted Spotify playlist ID found for Tidal download modal');
showToast('Unable to open download modal - missing playlist data', 'error');
}
}
}
async function rehydrateTidalDownloadModal(playlistId, state) {
try {
console.log(`💧 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}`);
}
}
}
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 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 newCardHtml = createTidalCard(state.playlist);
card.outerHTML = newCardHtml;
// Re-attach click handler
const newCard = document.getElementById(`tidal-card-${playlistId}`);
if (newCard) {
newCard.addEventListener('click', () => handleTidalCardClick(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>
`;
}
progressElement.innerHTML = statusCounterHTML || '<div class="playlist-card-sync-status">🔄 Starting...</div>';
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.style.display = 'none';
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';
// Always check server state first to detect any active wishlist processes
const response = await fetch('/api/active-processes');
if (response.ok) {
const data = await response.json();
const processes = data.active_processes || [];
// Look for active wishlist process on server
const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId);
const clientWishlistProcess = activeDownloadProcesses[playlistId];
if (serverWishlistProcess) {
console.log('Server has active wishlist process:', serverWishlistProcess.batch_id);
// Check if this is an auto-initiated batch (from auto-processing)
const isAutoInitiated = serverWishlistProcess.auto_initiated || false;
if (isAutoInitiated) {
console.log('🤖 [Auto-Processing] Detected auto-initiated wishlist batch');
// Clear any user closed state since user is actively requesting to see it
WishlistModalState.clearUserClosed();
}
// Check if client state is properly synchronized with server
const isClientStateMismatched = !clientWishlistProcess ||
clientWishlistProcess.batchId !== serverWishlistProcess.batch_id ||
clientWishlistProcess.status !== 'running';
const isModalElementMissing = !clientWishlistProcess?.modalElement ||
!document.body.contains(clientWishlistProcess.modalElement);
if (isClientStateMismatched || isModalElementMissing) {
console.log('Server/client wishlist state mismatch or missing modal, rehydrating...');
console.log('Client batch ID:', clientWishlistProcess?.batchId, 'Server batch ID:', serverWishlistProcess.batch_id);
console.log('Client status:', clientWishlistProcess?.status, 'Modal exists:', !isModalElementMissing);
await rehydrateModal(serverWishlistProcess, true); // Force rehydration with user request
return;
} else {
// Client state is properly synchronized, just show existing modal
console.log('Found properly synced wishlist process, showing modal');
if (clientWishlistProcess.modalElement) {
clientWishlistProcess.modalElement.style.display = 'flex';
WishlistModalState.setVisible(); // Track that modal is now visible
return;
}
}
}
}
// No active wishlist process on server, proceed with normal modal creation
// First check if there are any tracks in the wishlist
const countResponse = await fetch('/api/wishlist/count');
const countData = await countResponse.json();
if (countData.count === 0) {
showToast('Wishlist is empty. No tracks to download.', 'info');
return;
}
// Open the wishlist download modal for new process
console.log('No active server process, creating new wishlist modal');
await openDownloadMissingWishlistModal();
} catch (error) {
console.error('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>
`;
}
progressElement.innerHTML = statusCounterHTML || '<div class="playlist-card-sync-status">🔄 Starting...</div>';
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');
}
}
// --- Global Cleanup on Page Unload ---
// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed