Sanitizes wishlist track data for consistent backend/frontend handling, enhances wishlist process state reporting for better UI sync, and refactors frontend modal logic to prevent auto-show conflicts and ensure user-driven visibility. Also improves polling and rehydration to keep frontend state in sync with server-side auto-processing.
11757 lines
No EOL
473 KiB
JavaScript
11757 lines
No EOL
473 KiB
JavaScript
// SoulSync WebUI JavaScript - Replicating PyQt6 GUI Functionality
|
||
|
||
// Global state management
|
||
let currentPage = 'dashboard';
|
||
let currentTrack = null;
|
||
let isPlaying = false;
|
||
let mediaPlayerExpanded = false;
|
||
let donationAddressesVisible = false;
|
||
let searchResults = [];
|
||
let currentStream = {
|
||
status: 'stopped',
|
||
progress: 0,
|
||
track: null
|
||
};
|
||
|
||
// Streaming state management (enhanced functionality)
|
||
let streamStatusPoller = null;
|
||
let audioPlayer = null;
|
||
let streamPollingRetries = 0;
|
||
let streamPollingInterval = 1000; // Start with 1-second polling
|
||
const maxStreamPollingRetries = 10;
|
||
let allSearchResults = [];
|
||
let currentFilterType = 'all';
|
||
let currentFilterFormat = 'all';
|
||
let currentSortBy = 'quality_score';
|
||
let isSortReversed = false;
|
||
let searchAbortController = null;
|
||
let dbStatsInterval = null;
|
||
let dbUpdateStatusInterval = null;
|
||
let wishlistCountInterval = null;
|
||
|
||
// --- Add these globals for the Sync Page ---
|
||
let spotifyPlaylists = [];
|
||
let selectedPlaylists = new Set();
|
||
let activeSyncPollers = {}; // Key: playlist_id, Value: intervalId
|
||
let playlistTrackCache = {}; // Key: playlist_id, Value: tracks array
|
||
let spotifyPlaylistsLoaded = false;
|
||
let activeDownloadProcesses = {};
|
||
let sequentialSyncManager = null;
|
||
|
||
// --- YouTube Playlist State Management ---
|
||
let youtubePlaylistStates = {}; // Key: url_hash, Value: playlist state
|
||
let activeYouTubePollers = {}; // Key: url_hash, Value: intervalId
|
||
|
||
// --- Tidal Playlist State Management (Similar to YouTube but loads from API like Spotify) ---
|
||
let tidalPlaylists = [];
|
||
let tidalPlaylistStates = {}; // Key: playlist_id, Value: playlist state with phases
|
||
let tidalPlaylistsLoaded = false;
|
||
|
||
// --- Artists Page State Management ---
|
||
let artistsPageState = {
|
||
currentView: 'search', // 'search', 'results', 'detail'
|
||
searchQuery: '',
|
||
searchResults: [],
|
||
selectedArtist: null,
|
||
artistDiscography: {
|
||
albums: [],
|
||
singles: []
|
||
},
|
||
cache: {
|
||
searches: {}, // Cache search results by query
|
||
discography: {}, // Cache discography by artist ID
|
||
colors: {}, // Cache extracted colors by image URL
|
||
completionData: {} // Cache completion data by artist ID
|
||
},
|
||
isInitialized: false // Track if the page has been initialized
|
||
};
|
||
|
||
// --- Artist Downloads Management State ---
|
||
let artistDownloadBubbles = {}; // Track artist download bubbles: artistId -> { artist, downloads: [], element }
|
||
let artistDownloadModalOpen = false; // Track if artist download modal is open
|
||
let downloadsUpdateTimeout = null; // Debounce downloads section updates
|
||
let artistsSearchTimeout = null;
|
||
let artistsSearchController = null;
|
||
|
||
// --- Wishlist Modal Persistence State Management ---
|
||
const WishlistModalState = {
|
||
// Track if wishlist modal was visible before page refresh
|
||
setVisible: function() {
|
||
localStorage.setItem('wishlist_modal_visible', 'true');
|
||
console.log('📱 [Modal State] Wishlist modal marked as visible in localStorage');
|
||
},
|
||
|
||
setHidden: function() {
|
||
localStorage.setItem('wishlist_modal_visible', 'false');
|
||
console.log('📱 [Modal State] Wishlist modal marked as hidden in localStorage');
|
||
},
|
||
|
||
wasVisible: function() {
|
||
const visible = localStorage.getItem('wishlist_modal_visible') === 'true';
|
||
console.log(`📱 [Modal State] Checking if wishlist modal was visible: ${visible}`);
|
||
return visible;
|
||
},
|
||
|
||
clear: function() {
|
||
localStorage.removeItem('wishlist_modal_visible');
|
||
console.log('📱 [Modal State] Cleared wishlist modal visibility state');
|
||
},
|
||
|
||
// Track if user manually closed the modal during auto-processing
|
||
setUserClosed: function() {
|
||
localStorage.setItem('wishlist_modal_user_closed', 'true');
|
||
console.log('📱 [Modal State] User manually closed wishlist modal during auto-processing');
|
||
},
|
||
|
||
clearUserClosed: function() {
|
||
localStorage.removeItem('wishlist_modal_user_closed');
|
||
console.log('📱 [Modal State] Cleared user closed state');
|
||
},
|
||
|
||
wasUserClosed: function() {
|
||
const closed = localStorage.getItem('wishlist_modal_user_closed') === 'true';
|
||
console.log(`📱 [Modal State] Checking if user closed modal: ${closed}`);
|
||
return closed;
|
||
}
|
||
};
|
||
|
||
// Sequential Sync Manager Class
|
||
class SequentialSyncManager {
|
||
constructor() {
|
||
this.queue = [];
|
||
this.currentIndex = 0;
|
||
this.isRunning = false;
|
||
this.startTime = null;
|
||
}
|
||
|
||
start(playlistIds) {
|
||
if (this.isRunning) {
|
||
console.warn('Sequential sync already running');
|
||
return;
|
||
}
|
||
|
||
// Convert playlist IDs to ordered array (maintain display order)
|
||
this.queue = Array.from(playlistIds);
|
||
this.currentIndex = 0;
|
||
this.isRunning = true;
|
||
this.startTime = Date.now();
|
||
|
||
console.log(`🚀 Starting sequential sync for ${this.queue.length} playlists:`, this.queue);
|
||
this.updateUI();
|
||
this.syncNext();
|
||
}
|
||
|
||
async syncNext() {
|
||
if (this.currentIndex >= this.queue.length) {
|
||
this.complete();
|
||
return;
|
||
}
|
||
|
||
const playlistId = this.queue[this.currentIndex];
|
||
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
|
||
console.log(`🔄 Sequential sync: Processing playlist ${this.currentIndex + 1}/${this.queue.length}: ${playlist?.name || playlistId}`);
|
||
|
||
this.updateUI();
|
||
|
||
try {
|
||
// Use existing single sync function
|
||
await startPlaylistSync(playlistId);
|
||
|
||
// Wait for sync to complete by monitoring the poller
|
||
await this.waitForSyncCompletion(playlistId);
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Sequential sync: Failed to sync playlist ${playlistId}:`, error);
|
||
showToast(`Failed to sync "${playlist?.name || playlistId}": ${error.message}`, 'error');
|
||
}
|
||
|
||
// Move to next playlist
|
||
this.currentIndex++;
|
||
setTimeout(() => this.syncNext(), 1000); // Small delay between syncs
|
||
}
|
||
|
||
async waitForSyncCompletion(playlistId) {
|
||
return new Promise((resolve) => {
|
||
// Monitor the existing sync poller for completion
|
||
const checkCompletion = () => {
|
||
if (!activeSyncPollers[playlistId]) {
|
||
// Poller stopped = sync completed
|
||
resolve();
|
||
return;
|
||
}
|
||
// Check again in 1 second
|
||
setTimeout(checkCompletion, 1000);
|
||
};
|
||
checkCompletion();
|
||
});
|
||
}
|
||
|
||
complete() {
|
||
const duration = ((Date.now() - this.startTime) / 1000).toFixed(1);
|
||
const completedCount = this.queue.length;
|
||
console.log(`🏁 Sequential sync completed in ${duration}s`);
|
||
|
||
this.isRunning = false;
|
||
this.queue = [];
|
||
this.currentIndex = 0;
|
||
this.startTime = null;
|
||
|
||
// Re-enable playlist selection
|
||
disablePlaylistSelection(false);
|
||
|
||
this.updateUI();
|
||
updateRefreshButtonState(); // Refresh button state after completion
|
||
showToast(`Sequential sync completed for ${completedCount} playlists in ${duration}s`, 'success');
|
||
}
|
||
|
||
cancel() {
|
||
if (!this.isRunning) return;
|
||
|
||
console.log('🛑 Cancelling sequential sync');
|
||
this.isRunning = false;
|
||
this.queue = [];
|
||
this.currentIndex = 0;
|
||
this.startTime = null;
|
||
|
||
// Re-enable playlist selection
|
||
disablePlaylistSelection(false);
|
||
|
||
this.updateUI();
|
||
updateRefreshButtonState(); // Refresh button state after cancellation
|
||
showToast('Sequential sync cancelled', 'info');
|
||
}
|
||
|
||
updateUI() {
|
||
const startSyncBtn = document.getElementById('start-sync-btn');
|
||
const selectionInfo = document.getElementById('selection-info');
|
||
|
||
if (!this.isRunning) {
|
||
// Reset to normal state
|
||
if (startSyncBtn) {
|
||
startSyncBtn.textContent = 'Start Sync';
|
||
startSyncBtn.disabled = selectedPlaylists.size === 0;
|
||
}
|
||
if (selectionInfo) {
|
||
const count = selectedPlaylists.size;
|
||
selectionInfo.textContent = count === 0
|
||
? 'Select playlists to sync'
|
||
: `${count} playlist${count > 1 ? 's' : ''} selected`;
|
||
}
|
||
} else {
|
||
// Show sequential sync status
|
||
if (startSyncBtn) {
|
||
startSyncBtn.textContent = 'Cancel Sequential Sync';
|
||
startSyncBtn.disabled = false;
|
||
}
|
||
if (selectionInfo) {
|
||
const current = this.currentIndex + 1;
|
||
const total = this.queue.length;
|
||
const currentPlaylist = spotifyPlaylists.find(p => p.id === this.queue[this.currentIndex]);
|
||
selectionInfo.textContent = `Syncing ${current}/${total}: ${currentPlaylist?.name || 'Unknown'}`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// API endpoints
|
||
const API = {
|
||
status: '/status',
|
||
config: '/config',
|
||
settings: '/api/settings',
|
||
testConnection: '/api/test-connection',
|
||
playlists: '/api/playlists',
|
||
sync: '/api/sync',
|
||
search: '/api/search',
|
||
artists: '/api/artists',
|
||
activity: '/api/activity',
|
||
stream: {
|
||
start: '/api/stream/start',
|
||
status: '/api/stream/status',
|
||
toggle: '/api/stream/toggle',
|
||
stop: '/api/stream/stop'
|
||
}
|
||
};
|
||
|
||
// ===============================
|
||
// INITIALIZATION
|
||
// ===============================
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
console.log('SoulSync WebUI initializing...');
|
||
|
||
// Initialize components
|
||
initializeNavigation();
|
||
initializeMediaPlayer();
|
||
initializeDonationWidget();
|
||
initializeSyncPage();
|
||
|
||
|
||
// Start periodic updates
|
||
updateServiceStatus();
|
||
setInterval(updateServiceStatus, 5000); // Every 5 seconds
|
||
|
||
// Start always-on download polling (batched, minimal overhead)
|
||
startGlobalDownloadPolling();
|
||
|
||
// Load initial data
|
||
loadInitialData();
|
||
|
||
// Handle window resize to re-check track title scrolling
|
||
window.addEventListener('resize', function() {
|
||
if (currentTrack) {
|
||
const trackTitleElement = document.getElementById('track-title');
|
||
const trackTitle = currentTrack.title || 'Unknown Track';
|
||
setTimeout(() => {
|
||
checkAndEnableScrolling(trackTitleElement, trackTitle);
|
||
}, 100); // Small delay to allow layout to settle
|
||
}
|
||
});
|
||
|
||
console.log('SoulSync WebUI initialized successfully!');
|
||
});
|
||
|
||
// ===============================
|
||
// NAVIGATION SYSTEM
|
||
// ===============================
|
||
|
||
function initializeNavigation() {
|
||
const navButtons = document.querySelectorAll('.nav-button');
|
||
|
||
navButtons.forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const page = button.getAttribute('data-page');
|
||
navigateToPage(page);
|
||
});
|
||
});
|
||
}
|
||
|
||
function navigateToPage(pageId) {
|
||
if (pageId === currentPage) return;
|
||
|
||
// Update navigation buttons
|
||
document.querySelectorAll('.nav-button').forEach(btn => {
|
||
btn.classList.remove('active');
|
||
});
|
||
document.querySelector(`[data-page="${pageId}"]`).classList.add('active');
|
||
|
||
// Update pages
|
||
document.querySelectorAll('.page').forEach(page => {
|
||
page.classList.remove('active');
|
||
});
|
||
document.getElementById(`${pageId}-page`).classList.add('active');
|
||
|
||
currentPage = pageId;
|
||
|
||
// Load page-specific data
|
||
loadPageData(pageId);
|
||
}
|
||
|
||
// REPLACE your old loadPageData function with this one:
|
||
// REPLACE your old loadPageData function with this corrected one
|
||
|
||
async function loadPageData(pageId) {
|
||
try {
|
||
// Stop any active polling when navigating away
|
||
stopDbStatsPolling();
|
||
stopDbUpdatePolling();
|
||
stopWishlistCountPolling();
|
||
switch (pageId) {
|
||
case 'dashboard':
|
||
await loadDashboardData();
|
||
break;
|
||
case 'sync':
|
||
initializeSyncPage();
|
||
await loadSyncData();
|
||
break;
|
||
case 'downloads':
|
||
initializeSearch();
|
||
initializeFilters();
|
||
await loadDownloadsData();
|
||
break;
|
||
case 'artists':
|
||
// Only fully initialize if not already initialized
|
||
if (!artistsPageState.isInitialized) {
|
||
initializeArtistsPage();
|
||
} else {
|
||
// Just restore state if already initialized
|
||
restoreArtistsPageState();
|
||
}
|
||
break;
|
||
case 'settings':
|
||
initializeSettings();
|
||
await loadSettingsData();
|
||
break;
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error loading ${pageId} data:`, error);
|
||
showToast(`Failed to load ${pageId} data`, 'error');
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// SERVICE STATUS MONITORING
|
||
// ===============================
|
||
|
||
async function updateServiceStatus() {
|
||
try {
|
||
const response = await fetch(API.status);
|
||
const data = await response.json();
|
||
|
||
// Update sidebar status indicators
|
||
updateStatusIndicator('spotify', data.spotify);
|
||
updateStatusIndicator('media-server', data.media_server);
|
||
updateStatusIndicator('soulseek', data.soulseek);
|
||
|
||
// Update media server name
|
||
const serverName = data.active_media_server === 'plex' ? 'Plex' : 'Jellyfin';
|
||
document.getElementById('media-server-name').textContent = serverName;
|
||
|
||
} catch (error) {
|
||
console.error('Error fetching status:', error);
|
||
// Set all to disconnected on error
|
||
updateStatusIndicator('spotify', false);
|
||
updateStatusIndicator('media-server', false);
|
||
updateStatusIndicator('soulseek', false);
|
||
}
|
||
}
|
||
|
||
function updateStatusIndicator(service, connected) {
|
||
const indicator = document.getElementById(`${service}-indicator`);
|
||
const dot = indicator.querySelector('.status-dot');
|
||
|
||
if (connected) {
|
||
dot.classList.remove('disconnected');
|
||
dot.classList.add('connected');
|
||
} else {
|
||
dot.classList.remove('connected');
|
||
dot.classList.add('disconnected');
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// MEDIA PLAYER FUNCTIONALITY
|
||
// ===============================
|
||
|
||
function initializeMediaPlayer() {
|
||
const trackTitle = document.getElementById('track-title');
|
||
const playButton = document.getElementById('play-button');
|
||
const stopButton = document.getElementById('stop-button');
|
||
const volumeSlider = document.getElementById('volume-slider');
|
||
|
||
// Initialize HTML5 audio player
|
||
audioPlayer = document.getElementById('audio-player');
|
||
if (audioPlayer) {
|
||
// Set up audio event listeners
|
||
audioPlayer.addEventListener('timeupdate', updateAudioProgress);
|
||
audioPlayer.addEventListener('ended', onAudioEnded);
|
||
audioPlayer.addEventListener('error', onAudioError);
|
||
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
|
||
audioPlayer.addEventListener('canplay', onAudioCanPlay);
|
||
|
||
// Set initial volume
|
||
audioPlayer.volume = 0.7; // 70%
|
||
volumeSlider.value = 70;
|
||
}
|
||
|
||
// Track title click - toggle expansion
|
||
trackTitle.addEventListener('click', toggleMediaPlayerExpansion);
|
||
|
||
// Media controls
|
||
playButton.addEventListener('click', handlePlayPause);
|
||
stopButton.addEventListener('click', handleStop);
|
||
volumeSlider.addEventListener('input', handleVolumeChange);
|
||
|
||
// Progress bar controls
|
||
const progressBar = document.getElementById('progress-bar');
|
||
if (progressBar) {
|
||
// Handle seeking
|
||
progressBar.addEventListener('input', handleProgressBarChange);
|
||
progressBar.addEventListener('mousedown', () => {
|
||
progressBar.dataset.seeking = 'true';
|
||
});
|
||
progressBar.addEventListener('mouseup', () => {
|
||
delete progressBar.dataset.seeking;
|
||
});
|
||
}
|
||
|
||
// Update volume slider styling
|
||
volumeSlider.addEventListener('input', updateVolumeSliderAppearance);
|
||
}
|
||
|
||
function toggleMediaPlayerExpansion() {
|
||
if (!currentTrack) return;
|
||
|
||
const mediaPlayer = document.getElementById('media-player');
|
||
const expandedContent = document.getElementById('media-expanded');
|
||
const noTrackMessage = document.getElementById('no-track-message');
|
||
|
||
mediaPlayerExpanded = !mediaPlayerExpanded;
|
||
|
||
if (mediaPlayerExpanded) {
|
||
mediaPlayer.style.minHeight = '145px';
|
||
expandedContent.classList.remove('hidden');
|
||
noTrackMessage.classList.add('hidden');
|
||
} else {
|
||
mediaPlayer.style.minHeight = '85px';
|
||
expandedContent.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function extractTrackTitle(filename) {
|
||
if (!filename) return null;
|
||
|
||
// Remove file extension
|
||
let title = filename.replace(/\.[^/.]+$/, '');
|
||
|
||
// Remove path components, keep only the filename
|
||
title = title.split('/').pop().split('\\').pop();
|
||
|
||
// Clean up common filename patterns
|
||
title = title
|
||
.replace(/^\d+\.?\s*/, '') // Remove track numbers at start
|
||
.replace(/^\d+\s*-\s*/, '') // Remove "01 - " patterns
|
||
.replace(/\s*-\s*\d{4}\s*$/, '') // Remove years at end
|
||
.replace(/\s*\[\d+kbps\].*$/, '') // Remove bitrate info
|
||
.replace(/\s*\(.*?\)\s*$/, '') // Remove parenthetical info at end
|
||
.trim();
|
||
|
||
return title || null;
|
||
}
|
||
|
||
function setTrackInfo(track) {
|
||
currentTrack = track;
|
||
|
||
const trackTitleElement = document.getElementById('track-title');
|
||
const trackTitle = track.title || 'Unknown Track';
|
||
|
||
// Set up the HTML structure for scrolling
|
||
trackTitleElement.innerHTML = `<span class="title-text">${escapeHtml(trackTitle)}</span>`;
|
||
|
||
document.getElementById('artist-name').textContent = track.artist || 'Unknown Artist';
|
||
document.getElementById('album-name').textContent = track.album || 'Unknown Album';
|
||
|
||
// Check if title needs scrolling (similar to GUI app)
|
||
setTimeout(() => {
|
||
checkAndEnableScrolling(trackTitleElement, trackTitle);
|
||
}, 100); // Allow DOM to settle
|
||
|
||
// Enable controls
|
||
document.getElementById('play-button').disabled = false;
|
||
document.getElementById('stop-button').disabled = false;
|
||
|
||
// Hide no track message
|
||
document.getElementById('no-track-message').classList.add('hidden');
|
||
|
||
// Auto-expand if collapsed
|
||
if (!mediaPlayerExpanded) {
|
||
toggleMediaPlayerExpansion();
|
||
}
|
||
}
|
||
|
||
function checkAndEnableScrolling(element, text) {
|
||
// Remove any existing scrolling class and reset styles
|
||
element.classList.remove('scrolling');
|
||
element.style.removeProperty('--scroll-distance');
|
||
|
||
// Force a layout to get accurate measurements
|
||
element.offsetWidth;
|
||
|
||
// Get the inner text element
|
||
const titleTextElement = element.querySelector('.title-text');
|
||
if (!titleTextElement) return;
|
||
|
||
// Check if text is wider than container
|
||
const containerWidth = element.offsetWidth;
|
||
const textWidth = titleTextElement.scrollWidth;
|
||
|
||
// Enable scrolling if text is significantly wider than container
|
||
if (textWidth > containerWidth + 15) {
|
||
const scrollDistance = containerWidth - textWidth;
|
||
element.style.setProperty('--scroll-distance', `${scrollDistance}px`);
|
||
element.classList.add('scrolling');
|
||
console.log(`📜 Enabled scrolling for title: "${text}"`);
|
||
console.log(`📜 Container: ${containerWidth}px, Text: ${textWidth}px, Scroll: ${scrollDistance}px`);
|
||
}
|
||
}
|
||
|
||
|
||
function clearTrack() {
|
||
// Force collapse the media player BEFORE clearing currentTrack
|
||
if (mediaPlayerExpanded) {
|
||
// Manually collapse since toggleMediaPlayerExpansion() needs currentTrack
|
||
mediaPlayerExpanded = false;
|
||
const mediaPlayer = document.getElementById('media-player');
|
||
const expandedContent = document.getElementById('media-expanded');
|
||
|
||
if (mediaPlayer) mediaPlayer.style.minHeight = '85px';
|
||
if (expandedContent) expandedContent.classList.add('hidden');
|
||
}
|
||
|
||
// Now clear track state
|
||
currentTrack = null;
|
||
isPlaying = false;
|
||
|
||
const trackTitleElement = document.getElementById('track-title');
|
||
trackTitleElement.innerHTML = '<span class="title-text">No track</span>';
|
||
trackTitleElement.classList.remove('scrolling'); // Remove scrolling animation
|
||
trackTitleElement.style.removeProperty('--scroll-distance'); // Clear CSS variable
|
||
|
||
document.getElementById('artist-name').textContent = 'Unknown Artist';
|
||
document.getElementById('album-name').textContent = 'Unknown Album';
|
||
document.getElementById('play-button').textContent = '▷';
|
||
document.getElementById('play-button').disabled = true;
|
||
document.getElementById('stop-button').disabled = true;
|
||
|
||
// Reset progress bar and time displays
|
||
const progressBar = document.getElementById('progress-bar');
|
||
const progressFill = document.getElementById('progress-fill');
|
||
if (progressBar) {
|
||
progressBar.value = 0;
|
||
delete progressBar.dataset.seeking;
|
||
}
|
||
if (progressFill) {
|
||
progressFill.style.width = '0%';
|
||
}
|
||
|
||
const currentTimeElement = document.getElementById('current-time');
|
||
const totalTimeElement = document.getElementById('total-time');
|
||
if (currentTimeElement) currentTimeElement.textContent = '0:00';
|
||
if (totalTimeElement) totalTimeElement.textContent = '0:00';
|
||
|
||
// Hide loading animation
|
||
hideLoadingAnimation();
|
||
|
||
// Show no track message
|
||
document.getElementById('no-track-message').classList.remove('hidden');
|
||
|
||
console.log('🧹 Track cleared and media player reset');
|
||
}
|
||
|
||
function setPlayingState(playing) {
|
||
isPlaying = playing;
|
||
const playButton = document.getElementById('play-button');
|
||
playButton.textContent = playing ? '⏸︎' : '▷';
|
||
}
|
||
|
||
async function handlePlayPause() {
|
||
// Use new streaming system toggle function
|
||
togglePlayback();
|
||
}
|
||
|
||
async function handleStop() {
|
||
// Use new streaming system stop function
|
||
await stopStream();
|
||
clearTrack();
|
||
}
|
||
|
||
function handleVolumeChange(event) {
|
||
const volume = event.target.value;
|
||
updateVolumeSliderAppearance();
|
||
|
||
// Update HTML5 audio player volume
|
||
if (audioPlayer) {
|
||
audioPlayer.volume = volume / 100;
|
||
}
|
||
}
|
||
|
||
function handleProgressBarChange(event) {
|
||
// Handle seeking in the audio track
|
||
if (!audioPlayer || !audioPlayer.duration) return;
|
||
|
||
const progress = parseFloat(event.target.value);
|
||
const newTime = (progress / 100) * audioPlayer.duration;
|
||
|
||
console.log(`🎯 Seeking to ${formatTime(newTime)} (${progress.toFixed(1)}%)`);
|
||
|
||
try {
|
||
audioPlayer.currentTime = newTime;
|
||
|
||
// Update visual progress immediately
|
||
const progressFill = document.getElementById('progress-fill');
|
||
if (progressFill) {
|
||
progressFill.style.width = `${progress}%`;
|
||
}
|
||
|
||
// Update time displays immediately
|
||
const currentTimeElement = document.getElementById('current-time');
|
||
if (currentTimeElement) {
|
||
currentTimeElement.textContent = formatTime(newTime);
|
||
}
|
||
} catch (error) {
|
||
console.warn('⚠️ Seek failed:', error.message);
|
||
// Reset progress bar to current position
|
||
const actualProgress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
|
||
event.target.value = actualProgress;
|
||
const progressFill = document.getElementById('progress-fill');
|
||
if (progressFill) {
|
||
progressFill.style.width = `${actualProgress}%`;
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateVolumeSliderAppearance() {
|
||
const slider = document.getElementById('volume-slider');
|
||
const value = slider.value;
|
||
slider.style.setProperty('--volume-percent', `${value}%`);
|
||
}
|
||
|
||
function showLoadingAnimation() {
|
||
document.getElementById('loading-animation').classList.remove('hidden');
|
||
}
|
||
|
||
function hideLoadingAnimation() {
|
||
document.getElementById('loading-animation').classList.add('hidden');
|
||
}
|
||
|
||
function setLoadingProgress(percentage) {
|
||
const loadingAnimation = document.getElementById('loading-animation');
|
||
const progressBar = loadingAnimation.querySelector('.loading-progress');
|
||
const loadingText = loadingAnimation.querySelector('.loading-text');
|
||
|
||
loadingAnimation.classList.remove('hidden');
|
||
progressBar.style.width = `${percentage}%`;
|
||
loadingText.textContent = `${Math.round(percentage)}%`;
|
||
}
|
||
|
||
// ===============================
|
||
// STREAMING FUNCTIONALITY
|
||
// ===============================
|
||
|
||
async function startStream(searchResult) {
|
||
// Start streaming a track - handles same track toggle and new track streaming
|
||
try {
|
||
console.log(`🎮 startStream() called with data:`, searchResult);
|
||
|
||
// Check if this is the same track that's currently playing/loading
|
||
const currentTrackId = currentTrack ? `${currentTrack.username}:${currentTrack.filename}` : null;
|
||
const newTrackId = `${searchResult.username}:${searchResult.filename}`;
|
||
|
||
console.log(`🎮 startStream() called for: ${searchResult.filename}`);
|
||
console.log(`🎮 Current track ID: ${currentTrackId}`);
|
||
console.log(`🎮 New track ID: ${newTrackId}`);
|
||
|
||
if (currentTrackId === newTrackId && audioPlayer && !audioPlayer.paused) {
|
||
// Same track clicked while playing - toggle pause
|
||
console.log("🔄 Toggling playback for same track");
|
||
togglePlayback();
|
||
return;
|
||
}
|
||
|
||
// Different track or no current track - start new stream
|
||
console.log("🎵 Starting new stream");
|
||
|
||
// Stop current streaming/playback if any
|
||
await stopStream();
|
||
|
||
// Set track info and show loading state
|
||
setTrackInfo({
|
||
title: extractTrackTitle(searchResult.filename) || searchResult.title || 'Unknown Track',
|
||
artist: searchResult.artist || searchResult.username || 'Unknown Artist',
|
||
album: searchResult.album || 'Unknown Album',
|
||
username: searchResult.username,
|
||
filename: searchResult.filename
|
||
});
|
||
|
||
showLoadingAnimation();
|
||
setLoadingProgress(0);
|
||
|
||
// Start streaming request
|
||
const response = await fetch(API.stream.start, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(searchResult)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (!data.success) {
|
||
throw new Error(data.error || 'Failed to start streaming');
|
||
}
|
||
|
||
console.log("✅ Stream started successfully");
|
||
|
||
// Start status polling
|
||
startStreamStatusPolling();
|
||
|
||
} catch (error) {
|
||
console.error('Error starting stream:', error);
|
||
showToast(`Failed to start stream: ${error.message}`, 'error');
|
||
hideLoadingAnimation();
|
||
clearTrack();
|
||
}
|
||
}
|
||
|
||
function startStreamStatusPolling() {
|
||
// Start polling for stream status updates with retry logic
|
||
if (streamStatusPoller) {
|
||
clearInterval(streamStatusPoller);
|
||
}
|
||
|
||
// Reset polling state
|
||
streamPollingRetries = 0;
|
||
streamPollingInterval = 1000; // Reset to 1-second interval
|
||
|
||
console.log('🔄 Starting enhanced stream status polling');
|
||
updateStreamStatus(); // Initial check
|
||
streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval);
|
||
}
|
||
|
||
function stopStreamStatusPolling() {
|
||
// Stop polling for stream status updates
|
||
if (streamStatusPoller) {
|
||
clearInterval(streamStatusPoller);
|
||
streamStatusPoller = null;
|
||
streamPollingRetries = 0;
|
||
streamPollingInterval = 1000; // Reset interval
|
||
console.log('⏹️ Stopped stream status polling');
|
||
}
|
||
}
|
||
|
||
async function updateStreamStatus() {
|
||
// Poll server for streaming progress and handle state changes with enhanced error recovery
|
||
try {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10-second timeout
|
||
|
||
const response = await fetch(API.stream.status, {
|
||
signal: controller.signal
|
||
});
|
||
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// Reset retry count on successful response
|
||
streamPollingRetries = 0;
|
||
streamPollingInterval = 1000; // Reset to normal interval
|
||
|
||
// Update current stream state
|
||
currentStream.status = data.status;
|
||
currentStream.progress = data.progress;
|
||
|
||
switch (data.status) {
|
||
case 'loading':
|
||
setLoadingProgress(data.progress);
|
||
// Update loading text with progress
|
||
const loadingText = document.querySelector('.loading-text');
|
||
if (loadingText && data.progress > 0) {
|
||
loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`;
|
||
}
|
||
break;
|
||
|
||
case 'queued':
|
||
// Show queue status with better messaging
|
||
const queueText = document.querySelector('.loading-text');
|
||
if (queueText) {
|
||
queueText.textContent = 'Queuing with uploader...';
|
||
}
|
||
setLoadingProgress(0); // Reset progress for queue state
|
||
break;
|
||
|
||
case 'ready':
|
||
// Stream is ready - start audio playback
|
||
console.log('🎵 Stream ready, starting audio playback');
|
||
stopStreamStatusPolling();
|
||
await startAudioPlayback();
|
||
break;
|
||
|
||
case 'error':
|
||
console.error('❌ Streaming error:', data.error_message);
|
||
stopStreamStatusPolling();
|
||
hideLoadingAnimation();
|
||
showToast(`Streaming error: ${data.error_message || 'Unknown error'}`, 'error');
|
||
clearTrack();
|
||
break;
|
||
|
||
case 'stopped':
|
||
// Handle stopped state
|
||
console.log('🛑 Stream stopped');
|
||
stopStreamStatusPolling();
|
||
hideLoadingAnimation();
|
||
clearTrack();
|
||
break;
|
||
}
|
||
|
||
} catch (error) {
|
||
streamPollingRetries++;
|
||
console.warn(`Stream status polling error (attempt ${streamPollingRetries}):`, error.message);
|
||
|
||
if (streamPollingRetries >= maxStreamPollingRetries) {
|
||
// Too many consecutive failures - give up
|
||
console.error('❌ Stream status polling failed after maximum retries');
|
||
stopStreamStatusPolling();
|
||
hideLoadingAnimation();
|
||
showToast('Lost connection to streaming server', 'error');
|
||
clearTrack();
|
||
} else {
|
||
// Implement exponential backoff for retries
|
||
const backoffMultiplier = Math.min(streamPollingRetries, 5); // Max 5x backoff
|
||
streamPollingInterval = 1000 * backoffMultiplier;
|
||
|
||
// Restart polling with new interval
|
||
if (streamStatusPoller) {
|
||
clearInterval(streamStatusPoller);
|
||
streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval);
|
||
console.log(`🔄 Retrying stream status polling with ${streamPollingInterval}ms interval`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function startAudioPlayback() {
|
||
// Start HTML5 audio playback of the streamed file with enhanced state management
|
||
try {
|
||
if (!audioPlayer) {
|
||
throw new Error('Audio player not initialized');
|
||
}
|
||
|
||
// Show loading state while preparing audio
|
||
const loadingText = document.querySelector('.loading-text');
|
||
if (loadingText) {
|
||
loadingText.textContent = 'Preparing playback...';
|
||
}
|
||
|
||
// Set audio source with cache-busting timestamp
|
||
const audioUrl = `/stream/audio?t=${new Date().getTime()}`;
|
||
console.log(`🎵 Loading audio from: ${audioUrl}`);
|
||
|
||
// Clear any existing source first
|
||
audioPlayer.pause();
|
||
audioPlayer.currentTime = 0;
|
||
audioPlayer.src = '';
|
||
|
||
// Set new source
|
||
audioPlayer.src = audioUrl;
|
||
audioPlayer.load(); // Force reload
|
||
|
||
// Wait for audio to be ready with promise-based approach
|
||
await new Promise((resolve, reject) => {
|
||
const timeout = setTimeout(() => {
|
||
reject(new Error('Audio loading timeout'));
|
||
}, 15000); // 15-second timeout
|
||
|
||
const onCanPlay = () => {
|
||
clearTimeout(timeout);
|
||
audioPlayer.removeEventListener('canplay', onCanPlay);
|
||
audioPlayer.removeEventListener('error', onError);
|
||
resolve();
|
||
};
|
||
|
||
const onError = (event) => {
|
||
clearTimeout(timeout);
|
||
audioPlayer.removeEventListener('canplay', onCanPlay);
|
||
audioPlayer.removeEventListener('error', onError);
|
||
const error = event.target.error || new Error('Audio loading failed');
|
||
reject(error);
|
||
};
|
||
|
||
audioPlayer.addEventListener('canplay', onCanPlay);
|
||
audioPlayer.addEventListener('error', onError);
|
||
|
||
// If already ready, resolve immediately
|
||
if (audioPlayer.readyState >= 3) { // HAVE_FUTURE_DATA
|
||
onCanPlay();
|
||
}
|
||
});
|
||
|
||
console.log('✅ Audio loaded and ready for playback');
|
||
|
||
// Try to start playback with retry logic
|
||
let retryCount = 0;
|
||
const maxRetries = 3;
|
||
|
||
while (retryCount < maxRetries) {
|
||
try {
|
||
await audioPlayer.play();
|
||
console.log('✅ Audio playback started successfully');
|
||
|
||
// Update UI to playing state
|
||
hideLoadingAnimation();
|
||
setPlayingState(true);
|
||
|
||
// Show media player if hidden
|
||
const noTrackMessage = document.getElementById('no-track-message');
|
||
if (noTrackMessage) {
|
||
noTrackMessage.classList.add('hidden');
|
||
}
|
||
|
||
// Ensure media player is expanded when playback starts
|
||
if (!mediaPlayerExpanded) {
|
||
toggleMediaPlayerExpansion();
|
||
}
|
||
|
||
// Update volume to current slider value
|
||
const volumeSlider = document.getElementById('volume-slider');
|
||
if (volumeSlider) {
|
||
audioPlayer.volume = volumeSlider.value / 100;
|
||
}
|
||
|
||
// Enable play/stop buttons
|
||
const playButton = document.getElementById('play-button');
|
||
const stopButton = document.getElementById('stop-button');
|
||
if (playButton) playButton.disabled = false;
|
||
if (stopButton) stopButton.disabled = false;
|
||
|
||
return; // Success!
|
||
|
||
} catch (playError) {
|
||
retryCount++;
|
||
console.warn(`⚠️ Audio play attempt ${retryCount} failed:`, playError.message);
|
||
|
||
if (retryCount >= maxRetries) {
|
||
throw playError; // Re-throw after max retries
|
||
}
|
||
|
||
// Wait before retry with exponential backoff
|
||
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
|
||
}
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error starting audio playback:', error);
|
||
hideLoadingAnimation();
|
||
|
||
// Provide user-friendly error messages
|
||
let userMessage = 'Playback failed';
|
||
|
||
if (error.message.includes('no supported source') ||
|
||
error.message.includes('Not supported') ||
|
||
error.message.includes('MEDIA_ELEMENT_ERROR')) {
|
||
userMessage = 'Audio format not supported by your browser. Try downloading instead.';
|
||
} else if (error.message.includes('network') || error.message.includes('fetch')) {
|
||
userMessage = 'Network error - please check your connection';
|
||
} else if (error.message.includes('decode')) {
|
||
userMessage = 'Audio file is corrupted or incompatible';
|
||
} else if (error.message.includes('timeout')) {
|
||
userMessage = 'Audio loading timeout - file may be too large';
|
||
} else if (error.message.includes('AbortError')) {
|
||
userMessage = 'Playback was interrupted';
|
||
}
|
||
|
||
showToast(userMessage, 'error');
|
||
clearTrack();
|
||
}
|
||
}
|
||
|
||
async function stopStream() {
|
||
// Stop streaming and clean up all state
|
||
try {
|
||
// Stop status polling
|
||
stopStreamStatusPolling();
|
||
|
||
// Stop audio playback
|
||
if (audioPlayer) {
|
||
audioPlayer.pause();
|
||
audioPlayer.src = '';
|
||
}
|
||
|
||
// Call backend stop endpoint
|
||
const response = await fetch(API.stream.stop, { method: 'POST' });
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
console.log('🛑 Stream stopped:', data.message);
|
||
}
|
||
|
||
// Reset UI state
|
||
hideLoadingAnimation();
|
||
setPlayingState(false);
|
||
|
||
// Reset stream state
|
||
currentStream = {
|
||
status: 'stopped',
|
||
progress: 0,
|
||
track: null
|
||
};
|
||
|
||
} catch (error) {
|
||
console.error('Error stopping stream:', error);
|
||
}
|
||
}
|
||
|
||
function togglePlayback() {
|
||
// Toggle play/pause for currently loaded audio
|
||
if (!audioPlayer || !currentTrack) {
|
||
console.log('⚠️ No audio player or track to toggle');
|
||
return;
|
||
}
|
||
|
||
if (audioPlayer.paused) {
|
||
audioPlayer.play()
|
||
.then(() => {
|
||
setPlayingState(true);
|
||
console.log('▶️ Resumed playback');
|
||
})
|
||
.catch(error => {
|
||
console.error('Error resuming playback:', error);
|
||
showToast('Failed to resume playback', 'error');
|
||
});
|
||
} else {
|
||
audioPlayer.pause();
|
||
setPlayingState(false);
|
||
console.log('⏸️ Paused playback');
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// AUDIO EVENT HANDLERS
|
||
// ===============================
|
||
|
||
function updateAudioProgress() {
|
||
// Update progress bar based on audio playback time
|
||
if (!audioPlayer || !audioPlayer.duration) return;
|
||
|
||
const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100;
|
||
|
||
// Update progress bar
|
||
const progressBar = document.getElementById('progress-bar');
|
||
const progressFill = document.getElementById('progress-fill');
|
||
if (progressBar && !progressBar.dataset.seeking) {
|
||
progressBar.value = progress;
|
||
// Update visual progress fill
|
||
if (progressFill) {
|
||
progressFill.style.width = `${progress}%`;
|
||
}
|
||
}
|
||
|
||
// Update time display
|
||
const currentTimeElement = document.getElementById('current-time');
|
||
const totalTimeElement = document.getElementById('total-time');
|
||
|
||
if (currentTimeElement) {
|
||
currentTimeElement.textContent = formatTime(audioPlayer.currentTime);
|
||
}
|
||
if (totalTimeElement) {
|
||
totalTimeElement.textContent = formatTime(audioPlayer.duration);
|
||
}
|
||
}
|
||
|
||
function onAudioEnded() {
|
||
// Handle audio playback completion
|
||
console.log('🏁 Audio playback ended');
|
||
setPlayingState(false);
|
||
|
||
// Reset progress to beginning
|
||
const progressBar = document.getElementById('progress-bar');
|
||
const progressFill = document.getElementById('progress-fill');
|
||
if (progressBar) {
|
||
progressBar.value = 0;
|
||
}
|
||
if (progressFill) {
|
||
progressFill.style.width = '0%';
|
||
}
|
||
|
||
const currentTimeElement = document.getElementById('current-time');
|
||
if (currentTimeElement) {
|
||
currentTimeElement.textContent = '0:00';
|
||
}
|
||
|
||
// TODO: Auto-advance to next track if queue exists
|
||
}
|
||
|
||
function onAudioError(event) {
|
||
// Handle audio playback errors
|
||
const error = event.target.error;
|
||
console.error('❌ Audio error:', error);
|
||
|
||
// Don't show error toast if it's just a format/codec issue and retrying
|
||
if (error && error.code) {
|
||
console.error(`Audio error code: ${error.code}, message: ${error.message || 'Unknown error'}`);
|
||
|
||
// Only show user-facing errors for serious issues
|
||
if (error.code === 4) { // MEDIA_ELEMENT_ERROR: Media not supported
|
||
console.warn('⚠️ Media format not supported by browser, but streaming may still work');
|
||
// Don't clear track or show error - let retry logic handle it
|
||
return;
|
||
}
|
||
}
|
||
|
||
hideLoadingAnimation();
|
||
|
||
// Only clear track after a short delay to allow for recovery
|
||
setTimeout(() => {
|
||
if (audioPlayer && audioPlayer.error) {
|
||
let userMessage = 'Audio format not supported by your browser. Try downloading instead.';
|
||
|
||
if (error && error.code) {
|
||
switch (error.code) {
|
||
case 1: // MEDIA_ERR_ABORTED
|
||
userMessage = 'Playback was stopped';
|
||
break;
|
||
case 2: // MEDIA_ERR_NETWORK
|
||
userMessage = 'Network error - please try again';
|
||
break;
|
||
case 3: // MEDIA_ERR_DECODE
|
||
userMessage = 'Audio file is corrupted or incompatible';
|
||
break;
|
||
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
|
||
userMessage = 'Audio format not supported by your browser. Try downloading instead.';
|
||
break;
|
||
}
|
||
}
|
||
|
||
showToast(userMessage, 'error');
|
||
clearTrack();
|
||
}
|
||
}, 2000);
|
||
}
|
||
|
||
function onAudioLoadStart() {
|
||
// Handle audio load start
|
||
console.log('🔄 Audio loading started');
|
||
}
|
||
|
||
function onAudioCanPlay() {
|
||
// Handle when audio can start playing
|
||
console.log('✅ Audio ready to play');
|
||
}
|
||
|
||
function formatTime(seconds) {
|
||
// Format seconds as MM:SS
|
||
if (!seconds || !isFinite(seconds)) return '0:00';
|
||
|
||
const minutes = Math.floor(seconds / 60);
|
||
const secs = Math.floor(seconds % 60);
|
||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
// ===============================
|
||
// AUDIO FORMAT SUPPORT DETECTION
|
||
// ===============================
|
||
|
||
function getFileExtension(filename) {
|
||
if (!filename) return '';
|
||
const ext = filename.toLowerCase().match(/\.([^.]+)$/);
|
||
return ext ? ext[1] : '';
|
||
}
|
||
|
||
function isAudioFormatSupported(filename) {
|
||
const ext = getFileExtension(filename);
|
||
const supportedFormats = ['mp3', 'ogg', 'wav']; // Most reliable formats
|
||
const partialSupport = ['flac', 'aac']; // Depends on browser
|
||
const unsupported = ['m4a', 'wma', 'ape', 'aiff']; // Generally problematic
|
||
|
||
if (supportedFormats.includes(ext)) {
|
||
return true;
|
||
}
|
||
|
||
if (partialSupport.includes(ext)) {
|
||
// Test if browser can actually play this format
|
||
return canPlayAudioFormat(ext);
|
||
}
|
||
|
||
return false; // Unsupported formats
|
||
}
|
||
|
||
function canPlayAudioFormat(extension) {
|
||
const audio = document.createElement('audio');
|
||
|
||
const mimeTypes = {
|
||
'mp3': 'audio/mpeg',
|
||
'ogg': 'audio/ogg; codecs="vorbis"',
|
||
'wav': 'audio/wav',
|
||
'flac': 'audio/flac',
|
||
'aac': 'audio/aac',
|
||
'm4a': 'audio/mp4',
|
||
'wma': 'audio/x-ms-wma'
|
||
};
|
||
|
||
const mimeType = mimeTypes[extension];
|
||
if (!mimeType) return false;
|
||
|
||
const canPlay = audio.canPlayType(mimeType);
|
||
return canPlay === 'probably' || canPlay === 'maybe';
|
||
}
|
||
|
||
// ===============================
|
||
// DONATION WIDGET
|
||
// ===============================
|
||
|
||
function initializeDonationWidget() {
|
||
const toggleButton = document.getElementById('donation-toggle');
|
||
toggleButton.addEventListener('click', toggleDonationAddresses);
|
||
}
|
||
|
||
function toggleDonationAddresses() {
|
||
const addresses = document.getElementById('donation-addresses');
|
||
const toggleButton = document.getElementById('donation-toggle');
|
||
|
||
donationAddressesVisible = !donationAddressesVisible;
|
||
|
||
if (donationAddressesVisible) {
|
||
addresses.classList.remove('hidden');
|
||
toggleButton.textContent = 'Hide';
|
||
} else {
|
||
addresses.classList.add('hidden');
|
||
toggleButton.textContent = 'Show';
|
||
}
|
||
}
|
||
|
||
function openKofi() {
|
||
window.open('https://ko-fi.com/boulderbadgedad', '_blank');
|
||
console.log('Opening Ko-fi link');
|
||
}
|
||
|
||
async function copyAddress(address, cryptoName) {
|
||
try {
|
||
await navigator.clipboard.writeText(address);
|
||
showToast(`${cryptoName} address copied to clipboard`, 'success');
|
||
console.log(`Copied ${cryptoName} address: ${address}`);
|
||
} catch (error) {
|
||
console.error('Failed to copy address:', error);
|
||
showToast(`Failed to copy ${cryptoName} address`, 'error');
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// SETTINGS FUNCTIONALITY
|
||
// ===============================
|
||
|
||
function initializeSettings() {
|
||
// This function is called when the settings page is loaded.
|
||
// It attaches event listeners to all interactive elements on the page.
|
||
|
||
// Main save button
|
||
const saveButton = document.getElementById('save-settings');
|
||
if (saveButton) {
|
||
saveButton.addEventListener('click', saveSettings);
|
||
}
|
||
|
||
// Server toggle buttons
|
||
const plexToggle = document.getElementById('plex-toggle');
|
||
if (plexToggle) {
|
||
plexToggle.addEventListener('click', () => toggleServer('plex'));
|
||
}
|
||
const jellyfinToggle = document.getElementById('jellyfin-toggle');
|
||
if (jellyfinToggle) {
|
||
jellyfinToggle.addEventListener('click', () => toggleServer('jellyfin'));
|
||
}
|
||
|
||
// Auto-detect buttons
|
||
const detectSlskdBtn = document.querySelector('#soulseek-url + .detect-button');
|
||
if (detectSlskdBtn) {
|
||
detectSlskdBtn.addEventListener('click', autoDetectSlskd);
|
||
}
|
||
const detectPlexBtn = document.querySelector('#plex-container .detect-button');
|
||
if (detectPlexBtn) {
|
||
detectPlexBtn.addEventListener('click', autoDetectPlex);
|
||
}
|
||
const detectJellyfinBtn = document.querySelector('#jellyfin-container .detect-button');
|
||
if (detectJellyfinBtn) {
|
||
detectJellyfinBtn.addEventListener('click', autoDetectJellyfin);
|
||
}
|
||
|
||
// Test connection buttons
|
||
// Test button event listeners removed - they use onclick attributes in HTML to avoid double firing
|
||
}
|
||
|
||
async function loadSettingsData() {
|
||
try {
|
||
const response = await fetch(API.settings);
|
||
const settings = await response.json();
|
||
|
||
// Populate Spotify settings
|
||
document.getElementById('spotify-client-id').value = settings.spotify?.client_id || '';
|
||
document.getElementById('spotify-client-secret').value = settings.spotify?.client_secret || '';
|
||
|
||
// Populate Tidal settings
|
||
document.getElementById('tidal-client-id').value = settings.tidal?.client_id || '';
|
||
document.getElementById('tidal-client-secret').value = settings.tidal?.client_secret || '';
|
||
|
||
// Populate Plex settings
|
||
document.getElementById('plex-url').value = settings.plex?.base_url || '';
|
||
document.getElementById('plex-token').value = settings.plex?.token || '';
|
||
|
||
// Populate Jellyfin settings
|
||
document.getElementById('jellyfin-url').value = settings.jellyfin?.base_url || '';
|
||
document.getElementById('jellyfin-api-key').value = settings.jellyfin?.api_key || '';
|
||
|
||
// Set active server and toggle visibility
|
||
const activeServer = settings.active_media_server || 'plex';
|
||
toggleServer(activeServer);
|
||
|
||
// Populate Soulseek settings
|
||
document.getElementById('soulseek-url').value = settings.soulseek?.slskd_url || '';
|
||
document.getElementById('soulseek-api-key').value = settings.soulseek?.api_key || '';
|
||
|
||
// Populate Download settings (right column)
|
||
document.getElementById('preferred-quality').value = settings.settings?.audio_quality || 'flac';
|
||
document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads';
|
||
document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer';
|
||
|
||
// Populate Database settings
|
||
document.getElementById('max-workers').value = settings.database?.max_workers || '5';
|
||
|
||
// Populate Metadata Enhancement settings
|
||
document.getElementById('metadata-enabled').checked = settings.metadata_enhancement?.enabled !== false;
|
||
document.getElementById('embed-album-art').checked = settings.metadata_enhancement?.embed_album_art !== false;
|
||
|
||
// Populate Playlist Sync settings
|
||
document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false;
|
||
|
||
// Populate Logging information (read-only)
|
||
document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO';
|
||
document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log';
|
||
|
||
} catch (error) {
|
||
console.error('Error loading settings:', error);
|
||
showToast('Failed to load settings', 'error');
|
||
}
|
||
}
|
||
|
||
function updateMediaServerFields() {
|
||
const serverType = document.getElementById('media-server-type').value;
|
||
const urlInput = document.getElementById('media-server-url');
|
||
const tokenInput = document.getElementById('media-server-token');
|
||
|
||
if (serverType === 'plex') {
|
||
urlInput.placeholder = 'http://localhost:32400';
|
||
tokenInput.placeholder = 'Plex Token';
|
||
} else {
|
||
urlInput.placeholder = 'http://localhost:8096';
|
||
tokenInput.placeholder = 'Jellyfin API Key';
|
||
}
|
||
}
|
||
|
||
function toggleServer(serverType) {
|
||
// Update toggle buttons
|
||
document.getElementById('plex-toggle').classList.remove('active');
|
||
document.getElementById('jellyfin-toggle').classList.remove('active');
|
||
document.getElementById(`${serverType}-toggle`).classList.add('active');
|
||
|
||
// Show/hide server containers
|
||
document.getElementById('plex-container').classList.toggle('hidden', serverType !== 'plex');
|
||
document.getElementById('jellyfin-container').classList.toggle('hidden', serverType !== 'jellyfin');
|
||
}
|
||
|
||
async function saveSettings() {
|
||
// Determine active server from toggle buttons
|
||
const activeServer = document.getElementById('plex-toggle').classList.contains('active') ? 'plex' : 'jellyfin';
|
||
|
||
const settings = {
|
||
active_media_server: activeServer,
|
||
spotify: {
|
||
client_id: document.getElementById('spotify-client-id').value,
|
||
client_secret: document.getElementById('spotify-client-secret').value
|
||
},
|
||
tidal: {
|
||
client_id: document.getElementById('tidal-client-id').value,
|
||
client_secret: document.getElementById('tidal-client-secret').value
|
||
},
|
||
plex: {
|
||
base_url: document.getElementById('plex-url').value,
|
||
token: document.getElementById('plex-token').value
|
||
},
|
||
jellyfin: {
|
||
base_url: document.getElementById('jellyfin-url').value,
|
||
api_key: document.getElementById('jellyfin-api-key').value
|
||
},
|
||
soulseek: {
|
||
slskd_url: document.getElementById('soulseek-url').value,
|
||
api_key: document.getElementById('soulseek-api-key').value,
|
||
download_path: document.getElementById('download-path').value,
|
||
transfer_path: document.getElementById('transfer-path').value
|
||
},
|
||
settings: {
|
||
audio_quality: document.getElementById('preferred-quality').value
|
||
},
|
||
database: {
|
||
max_workers: parseInt(document.getElementById('max-workers').value)
|
||
},
|
||
metadata_enhancement: {
|
||
enabled: document.getElementById('metadata-enabled').checked,
|
||
embed_album_art: document.getElementById('embed-album-art').checked
|
||
},
|
||
playlist_sync: {
|
||
create_backup: document.getElementById('create-backup').checked
|
||
}
|
||
};
|
||
|
||
try {
|
||
showLoadingOverlay('Saving settings...');
|
||
|
||
const response = await fetch(API.settings, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(settings)
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showToast('Settings saved successfully', 'success');
|
||
// Trigger immediate status update
|
||
setTimeout(updateServiceStatus, 1000);
|
||
} else {
|
||
showToast(`Failed to save settings: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving settings:', error);
|
||
showToast('Failed to save settings', 'error');
|
||
} finally {
|
||
hideLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
async function testConnection(service) {
|
||
try {
|
||
showLoadingOverlay(`Testing ${service} connection...`);
|
||
|
||
const response = await fetch(API.testConnection, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ service })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showToast(`${service} connection successful`, 'success');
|
||
} else {
|
||
showToast(`${service} connection failed: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error testing ${service} connection:`, error);
|
||
showToast(`Failed to test ${service} connection`, 'error');
|
||
} finally {
|
||
hideLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
// Individual Auto-detect functions - same as GUI
|
||
async function autoDetectPlex() {
|
||
try {
|
||
showLoadingOverlay('Auto-detecting Plex server...');
|
||
|
||
const response = await fetch('/api/detect-media-server', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ server_type: 'plex' })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
document.getElementById('plex-url').value = result.found_url;
|
||
showToast(`Plex server detected: ${result.found_url}`, 'success');
|
||
} else {
|
||
showToast(result.error, 'error');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error auto-detecting Plex:', error);
|
||
showToast('Failed to auto-detect Plex server', 'error');
|
||
} finally {
|
||
hideLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
async function autoDetectJellyfin() {
|
||
try {
|
||
showLoadingOverlay('Auto-detecting Jellyfin server...');
|
||
|
||
const response = await fetch('/api/detect-media-server', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ server_type: 'jellyfin' })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
document.getElementById('jellyfin-url').value = result.found_url;
|
||
showToast(`Jellyfin server detected: ${result.found_url}`, 'success');
|
||
} else {
|
||
showToast(result.error, 'error');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error auto-detecting Jellyfin:', error);
|
||
showToast('Failed to auto-detect Jellyfin server', 'error');
|
||
} finally {
|
||
hideLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
async function autoDetectSlskd() {
|
||
try {
|
||
showLoadingOverlay('Auto-detecting Soulseek (slskd) server...');
|
||
|
||
const response = await fetch('/api/detect-soulseek', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
document.getElementById('soulseek-url').value = result.found_url;
|
||
showToast(`Soulseek server detected: ${result.found_url}`, 'success');
|
||
} else {
|
||
showToast(result.error, 'error');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error auto-detecting Soulseek:', error);
|
||
showToast('Failed to auto-detect Soulseek server', 'error');
|
||
} finally {
|
||
hideLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
|
||
function cancelDetection(service) {
|
||
const progressDiv = document.getElementById(`${service}-detection-progress`);
|
||
progressDiv.classList.add('hidden');
|
||
showToast(`${service} detection cancelled`, 'error');
|
||
}
|
||
|
||
function updateStatusDisplays() {
|
||
// Update status displays based on current service status
|
||
// This would be called after status updates
|
||
const services = ['spotify', 'media-server', 'soulseek'];
|
||
services.forEach(service => {
|
||
const display = document.getElementById(`${service}-status-display`);
|
||
if (display) {
|
||
// Status will be updated by the regular status monitoring
|
||
}
|
||
});
|
||
}
|
||
|
||
async function authenticateTidal() {
|
||
try {
|
||
showLoadingOverlay('Starting Tidal authentication...');
|
||
// This would trigger the OAuth flow
|
||
showToast('Tidal authentication started', 'success');
|
||
// In a real implementation, this would open the OAuth URL
|
||
window.open('/auth/tidal', '_blank');
|
||
} catch (error) {
|
||
console.error('Error authenticating Tidal:', error);
|
||
showToast('Failed to start Tidal authentication', 'error');
|
||
} finally {
|
||
hideLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
function browsePath(pathType) {
|
||
showToast(`Path browser not available in web interface. Please enter path manually.`, 'error');
|
||
}
|
||
|
||
|
||
// ===============================
|
||
// SEARCH FUNCTIONALITY
|
||
// ===============================
|
||
|
||
function initializeSearch() {
|
||
// --- FIX: Corrected the element IDs to match the HTML ---
|
||
const searchInput = document.getElementById('downloads-search-input');
|
||
const searchButton = document.getElementById('downloads-search-btn');
|
||
|
||
// Add this line to get the cancel button
|
||
const cancelButton = document.getElementById('downloads-cancel-btn');
|
||
|
||
if (searchButton && searchInput) {
|
||
searchButton.addEventListener('click', performDownloadsSearch);
|
||
searchInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') performDownloadsSearch();
|
||
});
|
||
}
|
||
|
||
// Add this event listener for the cancel button
|
||
if (cancelButton) {
|
||
cancelButton.addEventListener('click', () => {
|
||
if (searchAbortController) {
|
||
searchAbortController.abort(); // This cancels the fetch request
|
||
console.log("Search cancelled by user.");
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
async function performSearch() {
|
||
const query = document.getElementById('search-input').value.trim();
|
||
if (!query) {
|
||
showToast('Please enter a search term', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
showLoadingOverlay('Searching...');
|
||
displaySearchResults([]); // Clear previous results
|
||
|
||
const response = await fetch(API.search, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ query })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
showToast(`Search error: ${data.error}`, 'error');
|
||
return;
|
||
}
|
||
|
||
searchResults = data.results || [];
|
||
displaySearchResults(searchResults);
|
||
|
||
if (searchResults.length === 0) {
|
||
showToast('No results found', 'error');
|
||
} else {
|
||
showToast(`Found ${searchResults.length} results`, 'success');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error performing search:', error);
|
||
showToast('Search failed', 'error');
|
||
} finally {
|
||
hideLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
function displaySearchResults(results) {
|
||
const resultsContainer = document.getElementById('search-results');
|
||
|
||
if (!results.length) {
|
||
resultsContainer.innerHTML = '<div class="no-results">No search results</div>';
|
||
return;
|
||
}
|
||
|
||
resultsContainer.innerHTML = results.map((result, index) => {
|
||
const isAlbum = result.type === 'album';
|
||
const sizeText = isAlbum ?
|
||
`${result.track_count || 0} tracks, ${(result.size_mb || 0).toFixed(1)} MB` :
|
||
`${(result.file_size / 1024 / 1024).toFixed(1)} MB, ${result.bitrate || 0}kbps`;
|
||
|
||
return `
|
||
<div class="search-result-item" onclick="selectResult(${index})">
|
||
<div class="result-header">
|
||
<div class="result-info">
|
||
<div class="result-title">${escapeHtml(result.title)}</div>
|
||
<div class="result-artist">${escapeHtml(result.artist)}</div>
|
||
${result.album ? `<div class="result-album">${escapeHtml(result.album)}</div>` : ''}
|
||
</div>
|
||
<div class="result-actions">
|
||
<button class="stream-button" onclick="event.stopPropagation(); streamTrack(${index})">
|
||
▷ Stream
|
||
</button>
|
||
<button class="download-button" onclick="event.stopPropagation(); startDownload(${index})">
|
||
⬇ Download
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="result-details">
|
||
<span class="result-size">${sizeText}</span>
|
||
<span class="result-user">by ${escapeHtml(result.username)}</span>
|
||
${result.quality ? `<span class="result-quality">${escapeHtml(result.quality)}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function selectResult(index) {
|
||
const result = searchResults[index];
|
||
if (!result) return;
|
||
|
||
console.log('Selected result:', result);
|
||
// Could show detailed view or additional actions here
|
||
}
|
||
|
||
|
||
async function startDownload(index) {
|
||
const result = searchResults[index];
|
||
if (!result) return;
|
||
|
||
try {
|
||
const response = await fetch('/api/downloads/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(result)
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showToast('Download started', 'success');
|
||
} else {
|
||
showToast(`Download failed: ${data.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error starting download:', error);
|
||
showToast('Failed to start download', 'error');
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// PAGE DATA LOADING
|
||
// ===============================
|
||
|
||
async function loadInitialData() {
|
||
try {
|
||
// Load artist bubble state first
|
||
await hydrateArtistBubblesFromSnapshot();
|
||
|
||
// Load dashboard data by default
|
||
await loadDashboardData();
|
||
} catch (error) {
|
||
console.error('Error loading initial data:', error);
|
||
}
|
||
}
|
||
|
||
async function loadDashboardData() {
|
||
try {
|
||
const response = await fetch(API.activity);
|
||
const data = await response.json();
|
||
|
||
const activityFeed = document.getElementById('activity-feed');
|
||
if (data.activities && data.activities.length) {
|
||
activityFeed.innerHTML = data.activities.map(activity => `
|
||
<div class="activity-item">
|
||
<span class="activity-time">${activity.time}</span>
|
||
<span class="activity-text">${escapeHtml(activity.text)}</span>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// Initialize wishlist count when dashboard loads
|
||
await updateWishlistCount();
|
||
|
||
// Start periodic refresh of wishlist count (every 30 seconds, matching GUI behavior)
|
||
stopWishlistCountPolling(); // Ensure no duplicates
|
||
wishlistCountInterval = setInterval(updateWishlistCount, 30000);
|
||
|
||
} catch (error) {
|
||
console.error('Error loading dashboard data:', error);
|
||
}
|
||
}
|
||
|
||
// ===========================================
|
||
// == SYNC PAGE SPOTIFY FUNCTIONALITY ==
|
||
// ===========================================
|
||
|
||
async function loadSyncData() {
|
||
// This is called when the sync page is navigated to.
|
||
if (!spotifyPlaylistsLoaded) {
|
||
await loadSpotifyPlaylists();
|
||
}
|
||
|
||
// Load YouTube playlists from backend (always refresh to get latest state)
|
||
await loadYouTubePlaylistsFromBackend();
|
||
}
|
||
|
||
async function checkForActiveProcesses() {
|
||
try {
|
||
const response = await fetch('/api/active-processes');
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
const processes = data.active_processes || [];
|
||
|
||
if (processes.length > 0) {
|
||
console.log(`🔄 Found ${processes.length} active process(es) from backend. Rehydrating UI...`);
|
||
|
||
// Separate download batch processes from YouTube playlist processes
|
||
const downloadProcesses = processes.filter(p => p.type === 'batch');
|
||
const youtubeProcesses = processes.filter(p => p.type === 'youtube_playlist');
|
||
|
||
console.log(`📊 Process breakdown: ${downloadProcesses.length} download batches, ${youtubeProcesses.length} YouTube playlists`);
|
||
|
||
// Rehydrate download modal processes (existing Spotify system)
|
||
for (const processInfo of downloadProcesses) {
|
||
if (!activeDownloadProcesses[processInfo.playlist_id]) {
|
||
rehydrateModal(processInfo);
|
||
}
|
||
}
|
||
|
||
// Note: YouTube playlists are handled by loadYouTubePlaylistsFromBackend() and rehydrateYouTubePlaylist()
|
||
// in loadSyncData(), which provides more complete data than active processes and handles download modal rehydration.
|
||
console.log(`ℹ️ Skipping ${youtubeProcesses.length} YouTube playlists - handled by full backend loading`);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to check for active processes:', error);
|
||
}
|
||
}
|
||
|
||
async function rehydrateArtistAlbumModal(virtualPlaylistId, playlistName, batchId) {
|
||
/**
|
||
* Rehydrates an artist album download modal from backend process data.
|
||
* Extracts artist/album info from virtual playlist ID and recreates the modal.
|
||
*/
|
||
try {
|
||
console.log(`💧 Rehydrating artist album modal: ${virtualPlaylistId} (${playlistName})`);
|
||
|
||
// Extract artist_id and album_id from virtualPlaylistId format: artist_album_[artist_id]_[album_id]
|
||
const parts = virtualPlaylistId.split('_');
|
||
if (parts.length < 4 || parts[0] !== 'artist' || parts[1] !== 'album') {
|
||
console.error(`❌ Invalid virtual playlist ID format: ${virtualPlaylistId}`);
|
||
return;
|
||
}
|
||
|
||
const artistId = parts[2];
|
||
const albumId = parts.slice(3).join('_'); // Handle album IDs that might contain underscores
|
||
|
||
console.log(`🔍 Extracted from virtual playlist: artistId=${artistId}, albumId=${albumId}`);
|
||
|
||
// Fetch the album tracks to get proper artist and album data
|
||
try {
|
||
const response = await fetch(`/api/artist/${artistId}/album/${albumId}/tracks`);
|
||
const data = await response.json();
|
||
|
||
if (!data.success || !data.album || !data.tracks) {
|
||
console.error('❌ Failed to fetch album data for rehydration:', data.error);
|
||
return;
|
||
}
|
||
|
||
const album = data.album;
|
||
const tracks = data.tracks;
|
||
|
||
// Extract artist info from the first track (all tracks should have same artist)
|
||
const artist = {
|
||
id: artistId,
|
||
name: tracks[0].artists[0] // Use first artist name from first track
|
||
};
|
||
|
||
console.log(`✅ Retrieved album data: "${album.name}" by ${artist.name} (${tracks.length} tracks)`);
|
||
|
||
// Create the modal using the same function as normal artist album downloads
|
||
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, tracks, album, artist);
|
||
|
||
// Update the rehydrated process with batch info and hide modal for background rehydration
|
||
const process = activeDownloadProcesses[virtualPlaylistId];
|
||
if (process) {
|
||
process.status = 'running';
|
||
process.batchId = batchId;
|
||
|
||
// Update button states to reflect running status
|
||
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
|
||
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
|
||
if (beginBtn) beginBtn.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
||
|
||
// Hide the modal - this is background rehydration, not user-requested
|
||
if (process.modalElement) {
|
||
process.modalElement.style.display = 'none';
|
||
console.log(`🔍 Hiding rehydrated modal for background processing: ${album.name}`);
|
||
}
|
||
|
||
console.log(`✅ Rehydrated artist album modal: ${artist.name} - ${album.name}`);
|
||
} else {
|
||
console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error fetching album data for rehydration:`, error);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error rehydrating artist album modal:`, error);
|
||
}
|
||
}
|
||
|
||
async function rehydrateModal(processInfo, userRequested = false) {
|
||
const { playlist_id, playlist_name, batch_id } = processInfo;
|
||
console.log(`💧 Rehydrating modal for "${playlist_name}" (batch: ${batch_id}) - User requested: ${userRequested}`);
|
||
|
||
// Handle YouTube virtual playlists - skip rehydration here, handled by YouTube system
|
||
if (playlist_id.startsWith('youtube_')) {
|
||
console.log(`⏭️ Skipping YouTube virtual playlist rehydration - handled by YouTube system`);
|
||
return;
|
||
}
|
||
|
||
// Handle artist album virtual playlists
|
||
if (playlist_id.startsWith('artist_album_')) {
|
||
console.log(`💧 Rehydrating artist album virtual playlist: ${playlist_id}`);
|
||
await rehydrateArtistAlbumModal(playlist_id, playlist_name, batch_id);
|
||
return;
|
||
}
|
||
|
||
// Handle wishlist processes specially
|
||
if (playlist_id === "wishlist") {
|
||
console.log(`💧 [Rehydrate] Handling wishlist modal for active process: ${batch_id}`);
|
||
|
||
// Check if modal already exists and is visible
|
||
const existingProcess = activeDownloadProcesses[playlist_id];
|
||
const modalAlreadyOpen = existingProcess && existingProcess.modalElement &&
|
||
existingProcess.modalElement.style.display === 'flex';
|
||
|
||
if (modalAlreadyOpen) {
|
||
console.log(`💧 [Rehydrate] Wishlist modal already open - updating existing modal with auto-process state`);
|
||
|
||
// Update existing process with new batch info
|
||
existingProcess.status = 'running';
|
||
existingProcess.batchId = batch_id;
|
||
|
||
// Update UI to reflect running state
|
||
const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`);
|
||
const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`);
|
||
if (beginBtn) beginBtn.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
||
|
||
// Ensure polling is active for live updates
|
||
if (!existingProcess.intervalId) {
|
||
console.log(`💧 [Rehydrate] Starting polling for existing modal`);
|
||
startModalDownloadPolling(playlist_id);
|
||
}
|
||
|
||
console.log(`✅ [Rehydrate] Successfully updated existing wishlist modal for auto-process`);
|
||
} else {
|
||
console.log(`💧 [Rehydrate] Creating new wishlist modal for active process: ${batch_id}`);
|
||
|
||
// Create the modal with current server state
|
||
await openDownloadMissingWishlistModal();
|
||
const process = activeDownloadProcesses[playlist_id];
|
||
if (!process) {
|
||
console.error('❌ [Rehydrate] Failed to create wishlist process in activeDownloadProcesses');
|
||
return;
|
||
}
|
||
|
||
// Sync process state with server
|
||
console.log(`✅ [Rehydrate] Syncing wishlist process state - batchId: ${batch_id}, status: running`);
|
||
process.status = 'running';
|
||
process.batchId = batch_id;
|
||
|
||
// Update UI to reflect running state
|
||
const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`);
|
||
const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`);
|
||
if (beginBtn) beginBtn.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
||
|
||
// Start polling for live updates
|
||
startModalDownloadPolling(playlist_id);
|
||
|
||
// SIMPLIFIED VISIBILITY LOGIC: Show modal if user requested it, otherwise keep hidden for background sync
|
||
if (userRequested) {
|
||
console.log('👤 [Rehydrate] User requested - showing wishlist modal');
|
||
process.modalElement.style.display = 'flex';
|
||
WishlistModalState.setVisible();
|
||
WishlistModalState.clearUserClosed();
|
||
} else {
|
||
console.log('🔄 [Rehydrate] Background sync - keeping modal hidden until user interaction');
|
||
process.modalElement.style.display = 'none';
|
||
WishlistModalState.setHidden();
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Handle regular Spotify playlist processes
|
||
let playlistData = spotifyPlaylists.find(p => p.id === playlist_id);
|
||
if (!playlistData) {
|
||
console.warn(`Cannot rehydrate modal: Playlist data for ${playlist_id} not loaded.`);
|
||
return;
|
||
}
|
||
await openDownloadMissingModal(playlist_id);
|
||
const process = activeDownloadProcesses[playlist_id];
|
||
if (!process) return;
|
||
|
||
process.status = 'running';
|
||
process.batchId = batch_id;
|
||
updatePlaylistCardUI(playlist_id);
|
||
updateRefreshButtonState();
|
||
|
||
document.getElementById(`begin-analysis-btn-${playlist_id}`).style.display = 'none';
|
||
document.getElementById(`cancel-all-btn-${playlist_id}`).style.display = 'inline-block';
|
||
|
||
startModalDownloadPolling(playlist_id);
|
||
|
||
process.modalElement.style.display = 'none';
|
||
}
|
||
|
||
// ===================================================================
|
||
// YOUTUBE PLAYLIST BACKEND HYDRATION FUNCTIONS
|
||
// ===================================================================
|
||
|
||
async function loadYouTubePlaylistsFromBackend() {
|
||
// Load all stored YouTube playlists from backend and recreate cards (similar to Spotify hydration)
|
||
try {
|
||
console.log('📋 Loading YouTube playlists from backend...');
|
||
|
||
const response = await fetch('/api/youtube/playlists');
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || 'Failed to fetch YouTube playlists');
|
||
}
|
||
|
||
const data = await response.json();
|
||
const playlists = data.playlists || [];
|
||
|
||
console.log(`🎬 Found ${playlists.length} stored YouTube playlists in backend`);
|
||
|
||
if (playlists.length === 0) {
|
||
console.log('📋 No YouTube playlists to hydrate');
|
||
return;
|
||
}
|
||
|
||
const container = document.getElementById('youtube-playlist-container');
|
||
|
||
// Create cards for playlists that don't already exist (avoid duplicates)
|
||
for (const playlistInfo of playlists) {
|
||
const urlHash = playlistInfo.url_hash;
|
||
|
||
// Check if card already exists (from rehydration or previous loading)
|
||
if (youtubePlaylistStates[urlHash] && youtubePlaylistStates[urlHash].cardElement &&
|
||
document.body.contains(youtubePlaylistStates[urlHash].cardElement)) {
|
||
console.log(`⏭️ Skipping existing YouTube playlist card: ${playlistInfo.playlist.name}`);
|
||
|
||
// Update existing state with backend data
|
||
const state = youtubePlaylistStates[urlHash];
|
||
state.phase = playlistInfo.phase;
|
||
state.discoveryProgress = playlistInfo.discovery_progress;
|
||
state.spotifyMatches = playlistInfo.spotify_matches;
|
||
state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id;
|
||
|
||
// Fetch discovery results for existing cards too if they don't have them
|
||
if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering' &&
|
||
(!state.discoveryResults || state.discoveryResults.length === 0)) {
|
||
try {
|
||
console.log(`🔍 Fetching missing discovery results for existing card: ${playlistInfo.playlist.name}`);
|
||
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
|
||
if (stateResponse.ok) {
|
||
const fullState = await stateResponse.json();
|
||
if (fullState.discovery_results) {
|
||
state.discoveryResults = fullState.discovery_results;
|
||
state.syncPlaylistId = fullState.sync_playlist_id;
|
||
state.syncProgress = fullState.sync_progress || {};
|
||
console.log(`✅ Restored ${state.discoveryResults.length} discovery results for existing card`);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn(`⚠️ Error fetching discovery results for existing card:`, error.message);
|
||
}
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
console.log(`🎬 Creating YouTube playlist card: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase})`);
|
||
createYouTubeCardFromBackendState(playlistInfo);
|
||
|
||
// Fetch discovery results for non-fresh playlists (same logic as rehydrateYouTubePlaylist)
|
||
if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering') {
|
||
try {
|
||
console.log(`🔍 Fetching discovery results for: ${playlistInfo.playlist.name}`);
|
||
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
|
||
if (stateResponse.ok) {
|
||
const fullState = await stateResponse.json();
|
||
console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`);
|
||
|
||
// Store discovery results in local state
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (fullState.discovery_results && state) {
|
||
state.discoveryResults = fullState.discovery_results;
|
||
state.syncPlaylistId = fullState.sync_playlist_id;
|
||
state.syncProgress = fullState.sync_progress || {};
|
||
console.log(`✅ Restored ${state.discoveryResults.length} discovery results for: ${playlistInfo.playlist.name}`);
|
||
}
|
||
} else {
|
||
console.warn(`⚠️ Could not fetch discovery results for: ${playlistInfo.playlist.name}`);
|
||
}
|
||
} catch (error) {
|
||
console.warn(`⚠️ Error fetching discovery results for ${playlistInfo.playlist.name}:`, error.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Rehydrate download modals for YouTube playlists in downloading/download_complete phases
|
||
for (const playlistInfo of playlists) {
|
||
if ((playlistInfo.phase === 'downloading' || playlistInfo.phase === 'download_complete') &&
|
||
playlistInfo.converted_spotify_playlist_id && playlistInfo.download_process_id) {
|
||
|
||
const convertedPlaylistId = playlistInfo.converted_spotify_playlist_id;
|
||
|
||
if (!activeDownloadProcesses[convertedPlaylistId]) {
|
||
console.log(`💧 Rehydrating download modal for YouTube playlist: ${playlistInfo.playlist.name}`);
|
||
try {
|
||
// Create the download modal using the YouTube-specific function
|
||
const spotifyTracks = youtubePlaylistStates[playlistInfo.url_hash]?.discoveryResults
|
||
?.filter(result => result.spotify_data)
|
||
?.map(result => result.spotify_data) || [];
|
||
|
||
if (spotifyTracks.length > 0) {
|
||
await openDownloadMissingModalForYouTube(
|
||
convertedPlaylistId,
|
||
playlistInfo.playlist.name,
|
||
spotifyTracks
|
||
);
|
||
|
||
// Set the modal to running state with the correct batch ID
|
||
const process = activeDownloadProcesses[convertedPlaylistId];
|
||
if (process) {
|
||
process.status = 'running';
|
||
process.batchId = playlistInfo.download_process_id;
|
||
|
||
// Update UI to running state
|
||
const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`);
|
||
const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`);
|
||
if (beginBtn) beginBtn.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
||
|
||
// Start polling for this process
|
||
startModalDownloadPolling(convertedPlaylistId);
|
||
|
||
// Hide modal since this is background rehydration
|
||
process.modalElement.style.display = 'none';
|
||
console.log(`✅ Rehydrated download modal for YouTube playlist: ${playlistInfo.playlist.name}`);
|
||
}
|
||
} else {
|
||
console.warn(`⚠️ No Spotify tracks found for YouTube download modal: ${playlistInfo.playlist.name}`);
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ Error rehydrating download modal for ${playlistInfo.playlist.name}:`, error);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(`✅ Successfully hydrated ${playlists.length} YouTube playlists from backend`);
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error loading YouTube playlists from backend:', error);
|
||
showToast(`Error loading YouTube playlists: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function createYouTubeCardFromBackendState(playlistInfo) {
|
||
// Create YouTube playlist card from backend state data
|
||
const urlHash = playlistInfo.url_hash;
|
||
const playlist = playlistInfo.playlist;
|
||
const phase = playlistInfo.phase;
|
||
|
||
const container = document.getElementById('youtube-playlist-container');
|
||
|
||
// Remove placeholder if it exists
|
||
const placeholder = container.querySelector('.youtube-playlist-placeholder');
|
||
if (placeholder) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
// Create card HTML (using EXACT same structure as createYouTubeCard)
|
||
const cardHtml = `
|
||
<div class="youtube-playlist-card" id="youtube-card-${urlHash}" data-url="${playlistInfo.url}" onclick="handleYouTubeCardClick('${urlHash}')">
|
||
<div class="playlist-card-icon youtube-icon">▶</div>
|
||
<div class="playlist-card-content">
|
||
<div class="playlist-card-name">${escapeHtml(playlist.name)}</div>
|
||
<div class="playlist-card-info">
|
||
<span class="playlist-card-track-count">${playlist.tracks.length} tracks</span>
|
||
<span class="playlist-card-phase-text" style="color: ${getPhaseColor(phase)};">${getPhaseText(phase)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}">
|
||
♪ ${playlistInfo.spotify_total} / ✓ ${playlistInfo.spotify_matches} / ✗ ${playlistInfo.spotify_total - playlistInfo.spotify_matches} / ${Math.round(getProgressWidth(playlistInfo))}%
|
||
</div>
|
||
<button class="playlist-card-action-btn">${getActionButtonText(phase)}</button>
|
||
</div>
|
||
`;
|
||
|
||
container.insertAdjacentHTML('beforeend', cardHtml);
|
||
|
||
// Store state for UI management (but backend remains source of truth)
|
||
youtubePlaylistStates[urlHash] = {
|
||
phase: phase,
|
||
url: playlistInfo.url,
|
||
playlist: playlist,
|
||
cardElement: document.getElementById(`youtube-card-${urlHash}`),
|
||
discoveryResults: [],
|
||
discoveryProgress: playlistInfo.discovery_progress,
|
||
spotifyMatches: playlistInfo.spotify_matches,
|
||
convertedSpotifyPlaylistId: playlistInfo.converted_spotify_playlist_id,
|
||
backendSynced: true // Flag to indicate this came from backend
|
||
};
|
||
|
||
console.log(`🃏 Created YouTube card from backend state: ${playlist.name} (${phase})`);
|
||
}
|
||
|
||
function getActionButtonText(phase) {
|
||
switch (phase) {
|
||
case 'fresh': return 'Discover';
|
||
case 'discovering': return 'View Progress';
|
||
case 'discovered': return 'View Results';
|
||
case 'syncing': return 'View Sync';
|
||
case 'sync_complete': return 'Download';
|
||
case 'downloading': return 'View Downloads';
|
||
case 'download_complete': return 'Complete';
|
||
default: return 'Open';
|
||
}
|
||
}
|
||
|
||
function getPhaseText(phase) {
|
||
switch (phase) {
|
||
case 'fresh': return 'Ready to discover';
|
||
case 'discovering': return 'Discovering...';
|
||
case 'discovered': return 'Discovery Complete';
|
||
case 'syncing': return 'Syncing...';
|
||
case 'sync_complete': return 'Sync Complete';
|
||
case 'downloading': return 'Downloading...';
|
||
case 'download_complete': return 'Download Complete';
|
||
default: return phase;
|
||
}
|
||
}
|
||
|
||
function getPhaseColor(phase) {
|
||
switch (phase) {
|
||
case 'fresh': return '#999';
|
||
case 'discovering': case 'syncing': case 'downloading': return '#ffa500';
|
||
case 'discovered': case 'sync_complete': case 'download_complete': return '#1db954';
|
||
default: return '#999';
|
||
}
|
||
}
|
||
|
||
function getProgressWidth(playlistInfo) {
|
||
if (playlistInfo.phase === 'fresh') return 0;
|
||
if (playlistInfo.spotify_total === 0) return 0;
|
||
return Math.round((playlistInfo.spotify_matches / playlistInfo.spotify_total) * 100);
|
||
}
|
||
|
||
async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) {
|
||
// Rehydrate a YouTube playlist's discovery modal state (similar to rehydrateModal)
|
||
const urlHash = playlistInfo.url_hash;
|
||
const playlistName = playlistInfo.playlist_name;
|
||
const phase = playlistInfo.phase;
|
||
|
||
console.log(`💧 Rehydrating YouTube playlist "${playlistName}" (Phase: ${phase}) - User requested: ${userRequested}`);
|
||
|
||
try {
|
||
// First, ensure the card exists (create from backend if needed)
|
||
if (!youtubePlaylistStates[urlHash] || !youtubePlaylistStates[urlHash].cardElement) {
|
||
console.log(`🃏 Creating missing YouTube card for rehydration: ${playlistName}`);
|
||
|
||
// Since playlistInfo from active processes doesn't have full playlist data,
|
||
// we need to fetch it from the backend first
|
||
try {
|
||
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
|
||
if (stateResponse.ok) {
|
||
const fullPlaylistState = await stateResponse.json();
|
||
createYouTubeCardFromBackendState(fullPlaylistState);
|
||
} else {
|
||
console.error(`❌ Could not fetch full playlist state for card creation: ${playlistName}`);
|
||
return; // Can't create card without playlist data
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ Error fetching playlist state for card creation: ${error.message}`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Fetch full state from backend to get discovery results
|
||
let fullState = null;
|
||
if (phase !== 'fresh' && phase !== 'discovering') {
|
||
try {
|
||
console.log(`🔍 Fetching full backend state for: ${playlistName}`);
|
||
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
|
||
if (stateResponse.ok) {
|
||
fullState = await stateResponse.json();
|
||
console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`);
|
||
}
|
||
} catch (error) {
|
||
console.warn(`⚠️ Could not fetch full state for ${playlistName}:`, error.message);
|
||
}
|
||
}
|
||
|
||
// Update local state to match backend
|
||
const state = youtubePlaylistStates[urlHash];
|
||
state.phase = phase;
|
||
state.discoveryProgress = playlistInfo.discovery_progress;
|
||
state.spotifyMatches = playlistInfo.spotify_matches;
|
||
state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id;
|
||
|
||
// Restore discovery results if we have them
|
||
if (fullState && fullState.discovery_results) {
|
||
state.discoveryResults = fullState.discovery_results;
|
||
state.syncPlaylistId = fullState.sync_playlist_id;
|
||
state.syncProgress = fullState.sync_progress || {};
|
||
console.log(`✅ Restored ${state.discoveryResults.length} discovery results from backend`);
|
||
|
||
// Update modal if it already exists
|
||
const existingModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
|
||
if (existingModal && !existingModal.classList.contains('hidden')) {
|
||
console.log(`🔄 Refreshing existing modal with restored discovery results`);
|
||
refreshYouTubeDiscoveryModalTable(urlHash);
|
||
}
|
||
}
|
||
|
||
// Update card display
|
||
updateYouTubeCardPhase(urlHash, phase);
|
||
updateYouTubeCardProgress(urlHash, playlistInfo);
|
||
|
||
// Handle active discovery polling
|
||
if (phase === 'discovering') {
|
||
console.log(`🔍 Resuming discovery polling for: ${playlistName}`);
|
||
startYouTubeDiscoveryPolling(urlHash);
|
||
}
|
||
|
||
// Open modal if user requested
|
||
if (userRequested) {
|
||
switch (phase) {
|
||
case 'discovering':
|
||
case 'discovered':
|
||
case 'syncing':
|
||
case 'sync_complete':
|
||
openYouTubeDiscoveryModal(urlHash);
|
||
break;
|
||
case 'downloading':
|
||
case 'download_complete':
|
||
// Open download modal if we have the converted playlist ID
|
||
if (playlistInfo.converted_spotify_playlist_id) {
|
||
await openDownloadMissingModal(playlistInfo.converted_spotify_playlist_id);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
console.log(`✅ Successfully rehydrated YouTube playlist: ${playlistName}`);
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error rehydrating YouTube playlist "${playlistName}":`, error);
|
||
}
|
||
}
|
||
|
||
async function removeYouTubePlaylistFromBackend(event, urlHash) {
|
||
// Remove YouTube playlist from backend storage and update UI
|
||
event.stopPropagation(); // Prevent card click
|
||
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state) return;
|
||
|
||
const playlistName = state.playlist.name;
|
||
|
||
try {
|
||
console.log(`🗑️ Removing YouTube playlist from backend: ${playlistName}`);
|
||
|
||
const response = await fetch(`/api/youtube/delete/${urlHash}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || 'Failed to delete playlist');
|
||
}
|
||
|
||
// Remove card from UI
|
||
if (state.cardElement) {
|
||
state.cardElement.remove();
|
||
}
|
||
|
||
// Remove from client state
|
||
delete youtubePlaylistStates[urlHash];
|
||
|
||
// Stop any active polling
|
||
if (activeYouTubePollers[urlHash]) {
|
||
clearInterval(activeYouTubePollers[urlHash]);
|
||
delete activeYouTubePollers[urlHash];
|
||
}
|
||
|
||
// Close discovery modal if open
|
||
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
|
||
if (modal) {
|
||
modal.remove();
|
||
}
|
||
|
||
// Show placeholder if no cards left
|
||
const container = document.getElementById('youtube-playlist-container');
|
||
const cards = container.querySelectorAll('.youtube-playlist-card');
|
||
if (cards.length === 0) {
|
||
container.innerHTML = '<div class="youtube-playlist-placeholder">No YouTube playlists added yet. Parse a YouTube playlist URL above to get started!</div>';
|
||
}
|
||
|
||
showToast(`Removed "${playlistName}" from backend storage`, 'success');
|
||
console.log(`✅ Successfully removed YouTube playlist: ${playlistName}`);
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error removing YouTube playlist "${playlistName}":`, error);
|
||
showToast(`Error removing playlist: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function loadSpotifyPlaylists() {
|
||
const container = document.getElementById('spotify-playlist-container');
|
||
const refreshBtn = document.getElementById('spotify-refresh-btn');
|
||
|
||
container.innerHTML = `<div class="playlist-placeholder">🔄 Loading playlists...</div>`;
|
||
refreshBtn.disabled = true;
|
||
refreshBtn.textContent = '🔄 Loading...';
|
||
|
||
try {
|
||
const response = await fetch('/api/spotify/playlists');
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || 'Failed to fetch playlists');
|
||
}
|
||
spotifyPlaylists = await response.json();
|
||
renderSpotifyPlaylists();
|
||
spotifyPlaylistsLoaded = true;
|
||
|
||
await checkForActiveProcesses();
|
||
|
||
} catch (error) {
|
||
container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${error.message}</div>`;
|
||
showToast(`Error loading playlists: ${error.message}`, 'error');
|
||
} finally {
|
||
refreshBtn.disabled = false;
|
||
refreshBtn.textContent = '🔄 Refresh';
|
||
}
|
||
}
|
||
|
||
function renderSpotifyPlaylists() {
|
||
const container = document.getElementById('spotify-playlist-container');
|
||
if (spotifyPlaylists.length === 0) {
|
||
container.innerHTML = `<div class="playlist-placeholder">No Spotify playlists found.</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = spotifyPlaylists.map(p => {
|
||
let statusClass = 'status-never-synced';
|
||
if (p.sync_status.startsWith('Synced')) statusClass = 'status-synced';
|
||
if (p.sync_status === 'Needs Sync') statusClass = 'status-needs-sync';
|
||
|
||
// This HTML structure creates the interactive playlist cards
|
||
return `
|
||
<div class="playlist-card" data-playlist-id="${p.id}" onclick="togglePlaylistSelection(event)">
|
||
<div class="playlist-card-main">
|
||
<div class="playlist-card-content">
|
||
<div class="playlist-card-name">${escapeHtml(p.name)}</div>
|
||
<div class="playlist-card-info">
|
||
<span>${p.track_count} tracks</span> •
|
||
<span class="playlist-card-status ${statusClass}">${p.sync_status}</span>
|
||
</div>
|
||
<div class="sync-progress-indicator" id="progress-${p.id}"></div>
|
||
</div>
|
||
<div class="playlist-card-actions">
|
||
<button id="action-btn-${p.id}" onclick="openPlaylistDetailsModal(event, '${p.id}')">Sync / Download</button>
|
||
<button id="progress-btn-${p.id}" class="view-progress-btn hidden" onclick="handleViewProgressClick(event, '${p.id}')">
|
||
View Progress
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function handleViewProgressClick(event, playlistId) {
|
||
event.stopPropagation(); // Prevent the card selection from toggling
|
||
const process = activeDownloadProcesses[playlistId];
|
||
|
||
if (process && process.modalElement) {
|
||
// If a process is active, just show its modal
|
||
console.log(`Re-opening active download modal for playlist ${playlistId}`);
|
||
process.modalElement.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
function updatePlaylistCardUI(playlistId) {
|
||
const process = activeDownloadProcesses[playlistId];
|
||
const progressBtn = document.getElementById(`progress-btn-${playlistId}`);
|
||
const actionBtn = document.getElementById(`action-btn-${playlistId}`);
|
||
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
|
||
|
||
if (!progressBtn || !actionBtn) return;
|
||
|
||
if (process && process.status === 'running') {
|
||
// A process is running: show the progress button
|
||
progressBtn.classList.remove('hidden');
|
||
progressBtn.textContent = 'View Progress';
|
||
progressBtn.style.backgroundColor = ''; // Reset any custom styling
|
||
actionBtn.textContent = '📥 Downloading...';
|
||
actionBtn.disabled = true;
|
||
|
||
// Remove completion styling from card
|
||
if (card) card.classList.remove('download-complete');
|
||
|
||
} else if (process && process.status === 'complete') {
|
||
// Process completed: show "ready for review" indicator
|
||
progressBtn.classList.remove('hidden');
|
||
progressBtn.textContent = '📋 View Results';
|
||
progressBtn.style.backgroundColor = '#28a745'; // Green success color
|
||
progressBtn.style.color = 'white';
|
||
actionBtn.textContent = '✅ Ready for Review';
|
||
actionBtn.disabled = false; // Allow clicking to see results
|
||
|
||
// Add completion styling to card
|
||
if (card) card.classList.add('download-complete');
|
||
|
||
} else {
|
||
// No process or it's been cleaned up: normal state
|
||
progressBtn.classList.add('hidden');
|
||
progressBtn.style.backgroundColor = ''; // Reset styling
|
||
progressBtn.style.color = ''; // Reset styling
|
||
actionBtn.textContent = 'Sync / Download';
|
||
actionBtn.disabled = false;
|
||
|
||
// Remove completion styling from card
|
||
if (card) card.classList.remove('download-complete');
|
||
}
|
||
}
|
||
|
||
async function cleanupDownloadProcess(playlistId) {
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (!process) return;
|
||
|
||
console.log(`🧹 Cleaning up download process for playlist ${playlistId}`);
|
||
|
||
// Stop any active polling first
|
||
if (process.poller) {
|
||
console.log(`🛑 Stopping individual polling for ${playlistId}`);
|
||
clearInterval(process.poller);
|
||
process.poller = null;
|
||
}
|
||
|
||
// Mark process as no longer running
|
||
if (process.status === 'running') {
|
||
process.status = 'complete';
|
||
}
|
||
|
||
// If the process has a batchId, tell the server to clean it up.
|
||
if (process.batchId) {
|
||
try {
|
||
console.log(`🚀 Sending cleanup request to server for batch: ${process.batchId}`);
|
||
await fetch('/api/playlists/cleanup_batch', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ batch_id: process.batchId })
|
||
});
|
||
console.log(`✅ Server cleanup completed for batch: ${process.batchId}`);
|
||
} catch (error) {
|
||
console.warn(`⚠️ Failed to send cleanup request to server:`, error);
|
||
// Don't show toast for cleanup failures - they're not user-facing
|
||
}
|
||
}
|
||
|
||
// Remove modal from DOM
|
||
if (process.modalElement && process.modalElement.parentElement) {
|
||
process.modalElement.parentElement.removeChild(process.modalElement);
|
||
}
|
||
|
||
// Remove from client-side global state
|
||
delete activeDownloadProcesses[playlistId];
|
||
|
||
// Check if global polling should be stopped
|
||
checkAndCleanupGlobalPolling();
|
||
|
||
// Restore card UI (only for non-wishlist playlists)
|
||
if (playlistId !== 'wishlist') {
|
||
updatePlaylistCardUI(playlistId);
|
||
}
|
||
updateRefreshButtonState(); // Now safe since hasActiveOperations() excludes wishlist
|
||
}
|
||
|
||
function togglePlaylistSelection(event) {
|
||
const card = event.currentTarget;
|
||
const playlistId = card.dataset.playlistId;
|
||
|
||
// Don't toggle if clicking the button
|
||
if (event.target.tagName === 'BUTTON') return;
|
||
|
||
const isSelected = !card.classList.contains('selected');
|
||
card.classList.toggle('selected', isSelected);
|
||
|
||
if (isSelected) {
|
||
selectedPlaylists.add(playlistId);
|
||
} else {
|
||
selectedPlaylists.delete(playlistId);
|
||
}
|
||
updateSyncActionsUI();
|
||
}
|
||
|
||
function updateSyncActionsUI() {
|
||
// If sequential sync is running, let the manager handle UI updates
|
||
if (sequentialSyncManager && sequentialSyncManager.isRunning) {
|
||
sequentialSyncManager.updateUI();
|
||
return;
|
||
}
|
||
|
||
const selectionInfo = document.getElementById('selection-info');
|
||
const startSyncBtn = document.getElementById('start-sync-btn');
|
||
const count = selectedPlaylists.size;
|
||
|
||
if (count === 0) {
|
||
if (selectionInfo) selectionInfo.textContent = 'Select playlists to sync';
|
||
if (startSyncBtn) startSyncBtn.disabled = true;
|
||
} else {
|
||
if (selectionInfo) selectionInfo.textContent = `${count} playlist${count > 1 ? 's' : ''} selected`;
|
||
if (startSyncBtn) startSyncBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function openPlaylistDetailsModal(event, playlistId) {
|
||
event.stopPropagation();
|
||
|
||
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
|
||
if (!playlist) return;
|
||
|
||
showLoadingOverlay(`Loading playlist: ${playlist.name}...`);
|
||
|
||
try {
|
||
// --- CACHING LOGIC START ---
|
||
if (playlistTrackCache[playlistId]) {
|
||
console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`);
|
||
// Use the cached tracks instead of fetching
|
||
const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] };
|
||
showPlaylistDetailsModal(fullPlaylist);
|
||
} else {
|
||
console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`);
|
||
// Fetch from the server if not in cache
|
||
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
|
||
const fullPlaylist = await response.json();
|
||
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
|
||
|
||
// Store the fetched tracks in the cache
|
||
playlistTrackCache[playlistId] = fullPlaylist.tracks;
|
||
console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`);
|
||
|
||
showPlaylistDetailsModal(fullPlaylist);
|
||
}
|
||
// --- CACHING LOGIC END ---
|
||
|
||
} catch (error) {
|
||
showToast(`Error: ${error.message}`, 'error');
|
||
} finally {
|
||
hideLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
function showPlaylistDetailsModal(playlist) {
|
||
// Create modal if it doesn't exist
|
||
let modal = document.getElementById('playlist-details-modal');
|
||
if (!modal) {
|
||
modal = document.createElement('div');
|
||
modal.id = 'playlist-details-modal';
|
||
modal.className = 'modal-overlay';
|
||
document.body.appendChild(modal);
|
||
}
|
||
|
||
// Check if there's a completed download missing tracks process for this playlist
|
||
const activeProcess = activeDownloadProcesses[playlist.id];
|
||
const hasCompletedProcess = activeProcess && activeProcess.status === 'complete';
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-container playlist-modal">
|
||
<div class="playlist-modal-header">
|
||
<div class="playlist-header-content">
|
||
<h2>${escapeHtml(playlist.name)}</h2>
|
||
<div class="playlist-quick-info">
|
||
<span class="playlist-track-count">${playlist.track_count} tracks</span>
|
||
<span class="playlist-owner">by ${escapeHtml(playlist.owner)}</span>
|
||
</div>
|
||
<!-- Sync status display (hidden by default, matches GUI) -->
|
||
<div class="playlist-modal-sync-status" id="modal-sync-status-${playlist.id}" style="display: none;">
|
||
<span class="sync-stat total-tracks">♪ <span id="modal-total-${playlist.id}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ <span id="modal-matched-${playlist.id}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ <span id="modal-failed-${playlist.id}">0</span></span>
|
||
<span class="sync-stat percentage">(<span id="modal-percentage-${playlist.id}">0</span>%)</span>
|
||
</div>
|
||
</div>
|
||
<span class="playlist-modal-close" onclick="closePlaylistDetailsModal()">×</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}')">×</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}')">×</span>
|
||
</div>
|
||
|
||
<div class="download-missing-modal-body">
|
||
<div class="download-dashboard-stats">
|
||
<div class="dashboard-stat stat-total">
|
||
<div class="dashboard-stat-number" id="stat-total-${virtualPlaylistId}">${spotifyTracks.length}</div>
|
||
<div class="dashboard-stat-label">Total Tracks</div>
|
||
</div>
|
||
<div class="dashboard-stat stat-found">
|
||
<div class="dashboard-stat-number" id="stat-found-${virtualPlaylistId}">-</div>
|
||
<div class="dashboard-stat-label">Found in Library</div>
|
||
</div>
|
||
<div class="dashboard-stat stat-missing">
|
||
<div class="dashboard-stat-number" id="stat-missing-${virtualPlaylistId}">-</div>
|
||
<div class="dashboard-stat-label">Missing Tracks</div>
|
||
</div>
|
||
<div class="dashboard-stat stat-downloaded">
|
||
<div class="dashboard-stat-number" id="stat-downloaded-${virtualPlaylistId}">0</div>
|
||
<div class="dashboard-stat-label">Downloaded</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-progress-section">
|
||
<div class="progress-item">
|
||
<div class="progress-label">
|
||
🔍 Library Analysis
|
||
<span id="analysis-progress-text-${virtualPlaylistId}">Ready to start</span>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill analysis" id="analysis-progress-fill-${virtualPlaylistId}"></div>
|
||
</div>
|
||
</div>
|
||
<div class="progress-item">
|
||
<div class="progress-label">
|
||
⏬ Downloads
|
||
<span id="download-progress-text-${virtualPlaylistId}">Waiting for analysis</span>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill download" id="download-progress-fill-${virtualPlaylistId}"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-tracks-section">
|
||
<div class="download-tracks-header">
|
||
<h3 class="download-tracks-title">📋 Track Analysis & Download Status</h3>
|
||
</div>
|
||
<div class="download-tracks-table-container">
|
||
<table class="download-tracks-table">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Track</th>
|
||
<th>Artist</th>
|
||
<th>Duration</th>
|
||
<th>Library Match</th>
|
||
<th>Download Status</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="download-tracks-tbody-${virtualPlaylistId}">
|
||
${spotifyTracks.map((track, index) => `
|
||
<tr data-track-index="${index}">
|
||
<td class="track-number">${index + 1}</td>
|
||
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
|
||
<td class="track-artist" title="${escapeHtml(track.artists.join(', '))}">${track.artists.join(', ')}</td>
|
||
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
|
||
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
|
||
<td class="track-download-status" id="download-${virtualPlaylistId}-${index}">-</td>
|
||
<td class="track-actions" id="actions-${virtualPlaylistId}-${index}">-</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-missing-modal-footer">
|
||
<div class="download-phase-controls">
|
||
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
|
||
Begin Analysis
|
||
</button>
|
||
<button class="download-control-btn danger" id="cancel-all-btn-${virtualPlaylistId}" onclick="cancelAllOperations('${virtualPlaylistId}')" style="display: none;">
|
||
Cancel All
|
||
</button>
|
||
</div>
|
||
<div class="modal-close-section">
|
||
<button class="download-control-btn secondary" onclick="closeDownloadMissingModal('${virtualPlaylistId}')">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
async function closeDownloadMissingModal(playlistId) {
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (!process) {
|
||
// If somehow called without a process, try to find and remove the element
|
||
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
|
||
if (modal && modal.parentElement) {
|
||
modal.parentElement.removeChild(modal);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// If the process is running, just hide the modal.
|
||
// If it's idle, complete, or cancelled, perform a full cleanup.
|
||
if (process.status === 'running') {
|
||
console.log(`Hiding active download modal for playlist ${playlistId}.`);
|
||
process.modalElement.style.display = 'none';
|
||
|
||
// Track wishlist modal state changes
|
||
if (playlistId === 'wishlist') {
|
||
WishlistModalState.setUserClosed(); // User manually closed during processing
|
||
console.log('📱 [Modal State] User manually closed wishlist modal during processing');
|
||
}
|
||
} else {
|
||
console.log(`Closing and cleaning up download modal for playlist ${playlistId}.`);
|
||
|
||
// Reset YouTube playlist phase to 'discovered' when modal is closed after completion
|
||
if (playlistId.startsWith('youtube_')) {
|
||
const urlHash = playlistId.replace('youtube_', '');
|
||
updateYouTubeCardPhase(urlHash, 'discovered');
|
||
|
||
// Update backend state to prevent rehydration issues on page refresh (similar to Tidal fix)
|
||
try {
|
||
const response = await fetch(`/api/youtube/update_phase/${urlHash}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
phase: 'discovered'
|
||
})
|
||
});
|
||
|
||
if (response.ok) {
|
||
console.log(`✅ [Modal Close] Updated backend phase for YouTube playlist ${urlHash} to 'discovered'`);
|
||
} else {
|
||
console.warn(`⚠️ [Modal Close] Failed to update backend phase for YouTube playlist ${urlHash}`);
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ [Modal Close] Error updating backend phase for YouTube playlist ${urlHash}:`, error);
|
||
}
|
||
}
|
||
|
||
// Enhanced Tidal playlist state management (based on GUI sync.py patterns)
|
||
if (playlistId.startsWith('tidal_')) {
|
||
const tidalPlaylistId = playlistId.replace('tidal_', '');
|
||
|
||
console.log(`🧹 [Modal Close] Processing Tidal playlist close: playlistId="${playlistId}", tidalPlaylistId="${tidalPlaylistId}"`);
|
||
console.log(`🧹 [Modal Close] Current Tidal state:`, tidalPlaylistStates[tidalPlaylistId]);
|
||
|
||
// Clear download-specific state but preserve discovery results (like GUI closeEvent)
|
||
if (tidalPlaylistStates[tidalPlaylistId]) {
|
||
const currentPhase = tidalPlaylistStates[tidalPlaylistId].phase;
|
||
console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`);
|
||
|
||
// Preserve discovery data for future use (like GUI modal behavior)
|
||
const preservedData = {
|
||
playlist: tidalPlaylistStates[tidalPlaylistId].playlist,
|
||
discovery_results: tidalPlaylistStates[tidalPlaylistId].discovery_results,
|
||
spotify_matches: tidalPlaylistStates[tidalPlaylistId].spotify_matches,
|
||
discovery_progress: tidalPlaylistStates[tidalPlaylistId].discovery_progress,
|
||
convertedSpotifyPlaylistId: tidalPlaylistStates[tidalPlaylistId].convertedSpotifyPlaylistId
|
||
};
|
||
|
||
// Clear download-specific state
|
||
delete tidalPlaylistStates[tidalPlaylistId].download_process_id;
|
||
delete tidalPlaylistStates[tidalPlaylistId].phase;
|
||
|
||
// Restore preserved data and set to discovered phase
|
||
Object.assign(tidalPlaylistStates[tidalPlaylistId], preservedData);
|
||
tidalPlaylistStates[tidalPlaylistId].phase = 'discovered';
|
||
|
||
console.log(`🧹 [Modal Close] Reset Tidal playlist ${tidalPlaylistId} - cleared download state, preserved discovery data`);
|
||
console.log(`🧹 [Modal Close] New phase after reset: ${tidalPlaylistStates[tidalPlaylistId].phase}`);
|
||
} else {
|
||
console.error(`❌ [Modal Close] No Tidal state found for playlistId: ${tidalPlaylistId}`);
|
||
}
|
||
|
||
updateTidalCardPhase(tidalPlaylistId, 'discovered');
|
||
console.log(`🔄 [Modal Close] Reset Tidal playlist ${tidalPlaylistId} to discovered phase`);
|
||
console.log(`📝 [Modal Close] Expected button text for discovered phase: "${getActionButtonText('discovered')}"`);
|
||
|
||
// Update backend state to prevent rehydration issues on page refresh
|
||
try {
|
||
const response = await fetch(`/api/tidal/update_phase/${tidalPlaylistId}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
phase: 'discovered'
|
||
})
|
||
});
|
||
|
||
if (response.ok) {
|
||
console.log(`✅ [Modal Close] Updated backend phase for Tidal playlist ${tidalPlaylistId} to 'discovered'`);
|
||
} else {
|
||
console.warn(`⚠️ [Modal Close] Failed to update backend phase for Tidal playlist ${tidalPlaylistId}`);
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ [Modal Close] Error updating backend phase for Tidal playlist ${tidalPlaylistId}:`, error);
|
||
}
|
||
}
|
||
|
||
// Clear wishlist modal state when modal is fully closed
|
||
if (playlistId === 'wishlist') {
|
||
WishlistModalState.clear(); // Clear all tracking since modal is fully closed
|
||
console.log('📱 [Modal State] Cleared wishlist modal state on full close');
|
||
}
|
||
|
||
// Clean up artist download if this is an artist album playlist
|
||
if (playlistId.startsWith('artist_album_')) {
|
||
console.log(`🧹 [MODAL CLOSE] Cleaning up artist download for completed modal: ${playlistId}`);
|
||
cleanupArtistDownload(playlistId);
|
||
console.log(`✅ [MODAL CLOSE] Artist download cleanup completed for: ${playlistId}`);
|
||
}
|
||
|
||
cleanupDownloadProcess(playlistId);
|
||
}
|
||
}
|
||
|
||
async function openDownloadMissingWishlistModal() {
|
||
const playlistId = "wishlist"; // Use a consistent ID for wishlist
|
||
|
||
// Check if a process is already active for the wishlist
|
||
if (activeDownloadProcesses[playlistId]) {
|
||
console.log(`Modal for wishlist already exists. Showing it.`);
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (process.modalElement) {
|
||
// Show helpful message if it's a completed process
|
||
if (process.status === 'complete') {
|
||
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
|
||
}
|
||
process.modalElement.style.display = 'flex';
|
||
WishlistModalState.setVisible(); // Track that modal is now visible
|
||
}
|
||
return; // Don't create a new one
|
||
}
|
||
|
||
console.log(`📥 Opening Download Missing Tracks modal for wishlist`);
|
||
|
||
// Fetch actual wishlist tracks from the server
|
||
let tracks;
|
||
try {
|
||
const response = await fetch('/api/wishlist/count');
|
||
const countData = await response.json();
|
||
if (countData.count === 0) {
|
||
showToast('Wishlist is empty. No tracks to download.', 'info');
|
||
return;
|
||
}
|
||
|
||
// Fetch the actual wishlist tracks for display
|
||
const tracksResponse = await fetch('/api/wishlist/tracks');
|
||
if (!tracksResponse.ok) {
|
||
throw new Error('Failed to fetch wishlist tracks');
|
||
}
|
||
const tracksData = await tracksResponse.json();
|
||
tracks = tracksData.tracks || [];
|
||
|
||
} catch (error) {
|
||
showToast(`Failed to fetch wishlist data: ${error.message}`, 'error');
|
||
return;
|
||
}
|
||
|
||
currentPlaylistTracks = tracks;
|
||
currentModalPlaylistId = playlistId;
|
||
|
||
let modal = document.createElement('div');
|
||
modal.id = `download-missing-modal-${playlistId}`; // Unique ID
|
||
modal.className = 'download-missing-modal'; // Use class for styling
|
||
modal.style.display = 'none'; // Start hidden
|
||
document.body.appendChild(modal);
|
||
|
||
// Register the new process in our global state tracker
|
||
activeDownloadProcesses[playlistId] = {
|
||
status: 'idle', // idle, running, complete, cancelled
|
||
modalElement: modal,
|
||
poller: null,
|
||
batchId: null,
|
||
playlist: { id: playlistId, name: "Wishlist" }, // Create a pseudo-playlist object
|
||
tracks: tracks
|
||
};
|
||
|
||
modal.innerHTML = `
|
||
<div class="download-missing-modal-content">
|
||
<div class="download-missing-modal-header">
|
||
<h2 class="download-missing-modal-title">Download Missing Tracks - Wishlist</h2>
|
||
<span class="download-missing-modal-close" onclick="closeDownloadMissingModal('${playlistId}')">×</span>
|
||
</div>
|
||
|
||
<div class="download-missing-modal-body">
|
||
<div class="download-dashboard-stats">
|
||
<div class="dashboard-stat stat-total">
|
||
<div class="dashboard-stat-number" id="stat-total-${playlistId}">${tracks.length}</div>
|
||
<div class="dashboard-stat-label">Total Tracks</div>
|
||
</div>
|
||
<div class="dashboard-stat stat-found">
|
||
<div class="dashboard-stat-number" id="stat-found-${playlistId}">-</div>
|
||
<div class="dashboard-stat-label">Found in Library</div>
|
||
</div>
|
||
<div class="dashboard-stat stat-missing">
|
||
<div class="dashboard-stat-number" id="stat-missing-${playlistId}">-</div>
|
||
<div class="dashboard-stat-label">Missing Tracks</div>
|
||
</div>
|
||
<div class="dashboard-stat stat-downloaded">
|
||
<div class="dashboard-stat-number" id="stat-downloaded-${playlistId}">0</div>
|
||
<div class="dashboard-stat-label">Downloaded</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-progress-section">
|
||
<div class="progress-item">
|
||
<div class="progress-label">
|
||
🔍 Library Analysis
|
||
<span id="analysis-progress-text-${playlistId}">Ready to start</span>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill analysis" id="analysis-progress-fill-${playlistId}"></div>
|
||
</div>
|
||
</div>
|
||
<div class="progress-item">
|
||
<div class="progress-label">
|
||
⏬ Downloads
|
||
<span id="download-progress-text-${playlistId}">Waiting for analysis</span>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill download" id="download-progress-fill-${playlistId}"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-tracks-section">
|
||
<div class="download-tracks-header">
|
||
<h3 class="download-tracks-title">📋 Track Analysis & Download Status</h3>
|
||
</div>
|
||
<div class="download-tracks-table-container">
|
||
<table class="download-tracks-table">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Track</th>
|
||
<th>Artist</th>
|
||
<th>Library Match</th>
|
||
<th>Download Status</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="download-tracks-tbody-${playlistId}">
|
||
${tracks.map((track, index) => `
|
||
<tr data-track-index="${index}">
|
||
<td class="track-number">${index + 1}</td>
|
||
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
|
||
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${formatArtists(track.artists)}</td>
|
||
<td class="track-match-status match-checking" id="match-${playlistId}-${index}">🔍 Pending</td>
|
||
<td class="track-download-status" id="download-${playlistId}-${index}">-</td>
|
||
<td class="track-actions" id="actions-${playlistId}-${index}">-</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-missing-modal-footer">
|
||
<div class="download-phase-controls">
|
||
<button class="download-control-btn primary" id="begin-analysis-btn-${playlistId}" onclick="startWishlistMissingTracksProcess('${playlistId}')">
|
||
Begin Analysis
|
||
</button>
|
||
<button class="download-control-btn danger" id="cancel-all-btn-${playlistId}" onclick="cancelAllOperations('${playlistId}')" style="display: none;">
|
||
Cancel All
|
||
</button>
|
||
<button class="download-control-btn danger" id="clear-wishlist-btn-${playlistId}" onclick="clearWishlist('${playlistId}')" style="margin-left: 10px;">
|
||
🗑️ Clear Wishlist
|
||
</button>
|
||
</div>
|
||
<div class="modal-close-section">
|
||
<button class="download-control-btn secondary" onclick="closeDownloadMissingModal('${playlistId}')">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
modal.style.display = 'flex';
|
||
WishlistModalState.setVisible(); // Track that new wishlist modal is now visible
|
||
}
|
||
|
||
async function startWishlistMissingTracksProcess(playlistId) {
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (!process) return;
|
||
|
||
console.log(`🚀 Kicking off wishlist missing tracks process`);
|
||
try {
|
||
process.status = 'running';
|
||
// Note: Wishlist processes don't affect sync page refresh button state
|
||
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none';
|
||
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block';
|
||
|
||
const response = await fetch('/api/wishlist/download_missing', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (!data.success) {
|
||
// Special handling for rate limit
|
||
if (response.status === 429) {
|
||
throw new Error(`${data.error} Try closing some other download processes first.`);
|
||
}
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
process.batchId = data.batch_id;
|
||
console.log(`✅ Wishlist process started successfully. Batch ID: ${data.batch_id}`);
|
||
|
||
// Start polling for updates
|
||
startModalDownloadPolling(playlistId);
|
||
|
||
} catch (error) {
|
||
console.error('Error starting wishlist missing tracks process:', error);
|
||
showToast(`Error: ${error.message}`, 'error');
|
||
|
||
// Reset UI state on error
|
||
process.status = 'idle';
|
||
// Note: Wishlist processes don't affect sync page refresh button state
|
||
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'inline-block';
|
||
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function startMissingTracksProcess(playlistId) {
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (!process) return;
|
||
|
||
console.log(`🚀 Kicking off unified missing tracks process for playlist: ${playlistId}`);
|
||
try {
|
||
process.status = 'running';
|
||
updatePlaylistCardUI(playlistId);
|
||
updateRefreshButtonState();
|
||
|
||
// Update YouTube playlist phase to 'downloading' if this is a YouTube playlist
|
||
if (playlistId.startsWith('youtube_')) {
|
||
const urlHash = playlistId.replace('youtube_', '');
|
||
updateYouTubeCardPhase(urlHash, 'downloading');
|
||
}
|
||
|
||
// Update Tidal playlist phase to 'downloading' if this is a Tidal playlist
|
||
if (playlistId.startsWith('tidal_')) {
|
||
const tidalPlaylistId = playlistId.replace('tidal_', '');
|
||
if (tidalPlaylistStates[tidalPlaylistId]) {
|
||
tidalPlaylistStates[tidalPlaylistId].phase = 'downloading';
|
||
updateTidalCardPhase(tidalPlaylistId, 'downloading');
|
||
console.log(`🔄 Updated Tidal playlist ${tidalPlaylistId} to downloading phase`);
|
||
}
|
||
}
|
||
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none';
|
||
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block';
|
||
|
||
const response = await fetch(`/api/playlists/${playlistId}/start-missing-process`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
tracks: process.tracks,
|
||
playlist_name: process.playlist.name
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (!data.success) {
|
||
// Special handling for rate limit
|
||
if (response.status === 429) {
|
||
throw new Error(`${data.error} Try closing some other download processes first.`);
|
||
}
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
process.batchId = data.batch_id;
|
||
startModalDownloadPolling(playlistId);
|
||
} catch (error) {
|
||
showToast(`Failed to start process: ${error.message}`, 'error');
|
||
process.status = 'cancelled';
|
||
cleanupDownloadProcess(playlistId);
|
||
}
|
||
}
|
||
|
||
|
||
function updateTrackAnalysisResults(playlistId, results) {
|
||
// Update match results for all rows (tracks are now pre-populated)
|
||
for (const result of results) {
|
||
const matchElement = document.getElementById(`match-${playlistId}-${result.track_index}`);
|
||
if (matchElement) {
|
||
matchElement.textContent = result.found ? '✅ Found' : '❌ Missing';
|
||
matchElement.className = `track-match-status ${result.found ? 'match-found' : 'match-missing'}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// ============================================================================
|
||
// GLOBAL BATCHED POLLING SYSTEM - Optimized for multiple concurrent modals
|
||
// ============================================================================
|
||
|
||
let globalDownloadStatusPoller = null;
|
||
let globalPollingFailureCount = 0; // Track consecutive failures for exponential backoff
|
||
let globalPollingBaseInterval = 2000; // Base polling interval in ms - MATCHES sync.py exactly
|
||
|
||
function startGlobalDownloadPolling() {
|
||
if (globalDownloadStatusPoller) {
|
||
console.debug('🔄 [Global Polling] Already running, skipping start');
|
||
return; // Prevent duplicate pollers
|
||
}
|
||
|
||
console.log('🔄 [Global Polling] Starting batched download status polling');
|
||
|
||
globalDownloadStatusPoller = setInterval(async () => {
|
||
// Get all active processes that need polling
|
||
const activeBatchIds = [];
|
||
const batchToPlaylistMap = {};
|
||
let hasOpenWishlistModal = false;
|
||
|
||
Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => {
|
||
if (process.batchId && process.status === 'running') {
|
||
activeBatchIds.push(process.batchId);
|
||
batchToPlaylistMap[process.batchId] = playlistId;
|
||
}
|
||
|
||
// Check if there's an open wishlist modal (visible and idle/waiting)
|
||
if (playlistId === 'wishlist' && process.modalElement &&
|
||
process.modalElement.style.display === 'flex' &&
|
||
(!process.batchId || process.status !== 'running')) {
|
||
hasOpenWishlistModal = true;
|
||
}
|
||
});
|
||
|
||
// Special handling for open wishlist modal - check for new auto-processing
|
||
if (hasOpenWishlistModal) {
|
||
try {
|
||
const response = await fetch('/api/active-processes');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const processes = data.active_processes || [];
|
||
const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist');
|
||
|
||
if (serverWishlistProcess) {
|
||
console.log('🔄 [Global Polling] Detected auto-processing for open wishlist modal - rehydrating');
|
||
await rehydrateModal(serverWishlistProcess, false); // false = not user-requested
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.debug('⚠️ [Global Polling] Failed to check for wishlist auto-processing:', error);
|
||
}
|
||
}
|
||
|
||
if (activeBatchIds.length === 0) {
|
||
console.debug('📊 [Global Polling] No active processes, continuing polling');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Single batched API call for all active processes
|
||
const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&');
|
||
const response = await fetch(`/api/download_status/batch?${queryParams}`);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.debug(`📊 [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`);
|
||
|
||
// Process each batch's status data using existing logic
|
||
Object.entries(data.batches).forEach(([batchId, statusData]) => {
|
||
const playlistId = batchToPlaylistMap[batchId];
|
||
if (!playlistId || statusData.error) {
|
||
if (statusData.error) {
|
||
console.error(`❌ [Global Polling] Error for batch ${batchId}:`, statusData.error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Use existing modal update logic - zero changes needed!
|
||
processModalStatusUpdate(playlistId, statusData);
|
||
});
|
||
|
||
// ENHANCED: Reset failure count on successful polling
|
||
globalPollingFailureCount = 0;
|
||
|
||
} catch (error) {
|
||
console.error('❌ [Global Polling] Batched request failed:', error);
|
||
|
||
// ENHANCED: Implement exponential backoff on failure
|
||
globalPollingFailureCount++;
|
||
|
||
if (globalPollingFailureCount >= 5) {
|
||
console.error(`🚨 [Global Polling] ${globalPollingFailureCount} consecutive failures, continuing with backoff`);
|
||
// Don't stop polling - just continue with exponential backoff
|
||
}
|
||
|
||
// Exponential backoff: increase interval temporarily
|
||
const backoffInterval = Math.min(globalPollingBaseInterval * Math.pow(2, globalPollingFailureCount - 1), 8000);
|
||
console.warn(`⚠️ [Global Polling] Failure ${globalPollingFailureCount}/5, backing off to ${backoffInterval}ms`);
|
||
|
||
// Temporarily adjust the polling interval
|
||
if (globalDownloadStatusPoller) {
|
||
clearInterval(globalDownloadStatusPoller);
|
||
globalDownloadStatusPoller = null;
|
||
|
||
// Restart with backoff interval
|
||
setTimeout(() => {
|
||
if (Object.keys(activeDownloadProcesses).length > 0) {
|
||
startGlobalDownloadPollingWithInterval(backoffInterval);
|
||
}
|
||
}, backoffInterval);
|
||
}
|
||
}
|
||
}, globalPollingBaseInterval); // Use base interval initially
|
||
}
|
||
|
||
function startGlobalDownloadPollingWithInterval(interval) {
|
||
if (globalDownloadStatusPoller) {
|
||
console.debug('🔄 [Global Polling] Already running, skipping start with interval');
|
||
return;
|
||
}
|
||
|
||
console.log(`🔄 [Global Polling] Starting with interval ${interval}ms`);
|
||
|
||
// Use the exact same logic as startGlobalDownloadPolling but with custom interval
|
||
globalDownloadStatusPoller = setInterval(async () => {
|
||
const activeBatchIds = [];
|
||
const batchToPlaylistMap = {};
|
||
let hasOpenWishlistModal = false;
|
||
|
||
Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => {
|
||
if (process.batchId && process.status === 'running') {
|
||
activeBatchIds.push(process.batchId);
|
||
batchToPlaylistMap[process.batchId] = playlistId;
|
||
}
|
||
|
||
// Check if there's an open wishlist modal (visible and idle/waiting)
|
||
if (playlistId === 'wishlist' && process.modalElement &&
|
||
process.modalElement.style.display === 'flex' &&
|
||
(!process.batchId || process.status !== 'running')) {
|
||
hasOpenWishlistModal = true;
|
||
}
|
||
});
|
||
|
||
// Special handling for open wishlist modal - check for new auto-processing
|
||
if (hasOpenWishlistModal) {
|
||
try {
|
||
const response = await fetch('/api/active-processes');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const processes = data.active_processes || [];
|
||
const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist');
|
||
|
||
if (serverWishlistProcess) {
|
||
console.log('🔄 [Global Polling] Detected auto-processing for open wishlist modal - rehydrating');
|
||
await rehydrateModal(serverWishlistProcess, false); // false = not user-requested
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.debug('⚠️ [Global Polling] Failed to check for wishlist auto-processing:', error);
|
||
}
|
||
}
|
||
|
||
if (activeBatchIds.length === 0) {
|
||
console.debug('📊 [Global Polling] No active processes, continuing polling');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&');
|
||
const response = await fetch(`/api/download_status/batch?${queryParams}`);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.debug(`📊 [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`);
|
||
|
||
Object.entries(data.batches).forEach(([batchId, statusData]) => {
|
||
const playlistId = batchToPlaylistMap[batchId];
|
||
if (!playlistId || statusData.error) {
|
||
if (statusData.error) {
|
||
console.error(`❌ [Global Polling] Error for batch ${batchId}:`, statusData.error);
|
||
}
|
||
return;
|
||
}
|
||
processModalStatusUpdate(playlistId, statusData);
|
||
});
|
||
|
||
// Success - reset to normal interval if we were backing off
|
||
globalPollingFailureCount = 0;
|
||
if (interval !== globalPollingBaseInterval) {
|
||
console.log('✅ [Global Polling] Recovered from backoff, returning to normal interval');
|
||
clearInterval(globalDownloadStatusPoller);
|
||
globalDownloadStatusPoller = null;
|
||
startGlobalDownloadPolling(); // Restart with normal interval
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ [Global Polling] Request failed:', error);
|
||
globalPollingFailureCount++;
|
||
|
||
if (globalPollingFailureCount >= 5) {
|
||
console.error(`🚨 [Global Polling] Too many failures, continuing with backoff`);
|
||
// Don't stop polling - just continue with exponential backoff
|
||
}
|
||
}
|
||
}, interval);
|
||
}
|
||
|
||
function stopGlobalDownloadPolling() {
|
||
if (globalDownloadStatusPoller) {
|
||
console.log('🛑 [Global Polling] Stopping batched download status polling');
|
||
clearInterval(globalDownloadStatusPoller);
|
||
globalDownloadStatusPoller = null;
|
||
}
|
||
}
|
||
|
||
function processModalStatusUpdate(playlistId, data) {
|
||
// This function contains ALL the existing polling logic from startModalDownloadPolling
|
||
// Extracted so it can be called from both individual and batched polling
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (!process) {
|
||
console.debug(`⚠️ [Status Update] No process found for ${playlistId}, skipping update`);
|
||
return;
|
||
}
|
||
|
||
if (data.error) {
|
||
console.error(`❌ [Status Update] Error for ${playlistId}: ${data.error}`);
|
||
return;
|
||
}
|
||
|
||
// ENHANCED: Validate response data to prevent UI corruption
|
||
if (!data || typeof data !== 'object') {
|
||
console.error(`❌ [Status Update] Invalid data for ${playlistId}:`, data);
|
||
return;
|
||
}
|
||
|
||
// ENHANCED: Validate task data structure
|
||
if (data.tasks && !Array.isArray(data.tasks)) {
|
||
console.error(`❌ [Status Update] Invalid tasks data for ${playlistId} - not an array:`, data.tasks);
|
||
return;
|
||
}
|
||
|
||
console.debug(`📊 [Status Update] Processing update for ${playlistId}: phase=${data.phase}, tasks=${(data.tasks || []).length}`);
|
||
|
||
// Note: Wishlist modal visibility is now managed by handleWishlistButtonClick() only
|
||
// Auto-show logic has been simplified to prevent conflicts
|
||
|
||
if (data.phase === 'analysis') {
|
||
const progress = data.analysis_progress;
|
||
const percent = progress.total > 0 ? (progress.processed / progress.total) * 100 : 0;
|
||
document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = `${percent}%`;
|
||
document.getElementById(`analysis-progress-text-${playlistId}`).textContent =
|
||
`${progress.processed}/${progress.total} tracks analyzed`;
|
||
if (data.analysis_results) {
|
||
updateTrackAnalysisResults(playlistId, data.analysis_results);
|
||
// Update stats when we first get analysis results
|
||
const foundCount = data.analysis_results.filter(r => r.found).length;
|
||
const missingCount = data.analysis_results.filter(r => !r.found).length;
|
||
document.getElementById(`stat-found-${playlistId}`).textContent = foundCount;
|
||
document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount;
|
||
}
|
||
} else if (data.phase === 'downloading' || data.phase === 'complete' || data.phase === 'error') {
|
||
console.debug(`📊 [Status Update] Processing ${data.phase} phase for playlistId: ${playlistId}, tasks: ${(data.tasks || []).length}`);
|
||
|
||
if (document.getElementById(`analysis-progress-fill-${playlistId}`).style.width !== '100%') {
|
||
document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = '100%';
|
||
document.getElementById(`analysis-progress-text-${playlistId}`).textContent = 'Analysis complete!';
|
||
if(data.analysis_results) {
|
||
updateTrackAnalysisResults(playlistId, data.analysis_results);
|
||
const foundCount = data.analysis_results.filter(r => r.found).length;
|
||
const missingCount = data.analysis_results.filter(r => !r.found).length;
|
||
document.getElementById(`stat-found-${playlistId}`).textContent = foundCount;
|
||
document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount;
|
||
}
|
||
}
|
||
const missingTracks = (data.analysis_results || []).filter(r => !r.found);
|
||
const missingCount = missingTracks.length;
|
||
let completedCount = 0;
|
||
let failedOrCancelledCount = 0;
|
||
|
||
// Verify modal exists before processing tasks
|
||
const modal = document.getElementById(`download-missing-modal-${playlistId}`);
|
||
if (!modal) {
|
||
console.error(`❌ [Status Update] Modal not found: download-missing-modal-${playlistId}`);
|
||
return;
|
||
}
|
||
|
||
(data.tasks || []).forEach(task => {
|
||
const row = document.querySelector(`#download-missing-modal-${playlistId} tr[data-track-index="${task.track_index}"]`);
|
||
if (!row) {
|
||
console.debug(`❌ [Status Update] Row not found for playlistId: ${playlistId}, track_index: ${task.track_index}`);
|
||
return;
|
||
}
|
||
|
||
// V2 SYSTEM: Check for persistent cancel state from backend
|
||
const isV2Task = task.playlist_id !== undefined; // V2 tasks have playlist_id
|
||
const cancelRequested = task.cancel_requested || false;
|
||
const uiState = task.ui_state || 'normal';
|
||
|
||
// Legacy protection for old system compatibility
|
||
if (row.dataset.locallyCancelled === 'true' && !isV2Task) {
|
||
failedOrCancelledCount++;
|
||
return; // Only skip for legacy system tasks
|
||
}
|
||
|
||
// Mark row with V2 system info
|
||
if (isV2Task) {
|
||
row.dataset.useV2System = 'true';
|
||
row.dataset.cancelRequested = cancelRequested.toString();
|
||
row.dataset.uiState = uiState;
|
||
}
|
||
|
||
row.dataset.taskId = task.task_id;
|
||
const statusEl = document.getElementById(`download-${playlistId}-${task.track_index}`);
|
||
const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`);
|
||
|
||
let statusText = '';
|
||
// V2 SYSTEM: Handle UI state override for cancelling tasks
|
||
if (isV2Task && uiState === 'cancelling' && task.status !== 'cancelled') {
|
||
statusText = '🔄 Cancelling...';
|
||
} else {
|
||
switch (task.status) {
|
||
case 'pending': statusText = '⏸️ Pending'; break;
|
||
case 'searching': statusText = '🔍 Searching...'; break;
|
||
case 'downloading': statusText = `⏬ Downloading... ${Math.round(task.progress || 0)}%`; break;
|
||
case 'post_processing': statusText = '⌛ Processing...'; break;
|
||
case 'completed': statusText = '✅ Completed'; completedCount++; break;
|
||
case 'failed': statusText = '❌ Failed'; failedOrCancelledCount++; break;
|
||
case 'cancelled': statusText = '🚫 Cancelled'; failedOrCancelledCount++; break;
|
||
default: statusText = `⚪ ${task.status}`; break;
|
||
}
|
||
}
|
||
|
||
if(statusEl) {
|
||
statusEl.textContent = statusText;
|
||
console.debug(`✅ [Status Update] Updated track ${task.track_index} to: ${statusText}${isV2Task ? ' (V2)' : ''}`);
|
||
} else {
|
||
console.warn(`❌ [Status Update] Status element not found: download-${playlistId}-${task.track_index}`);
|
||
}
|
||
|
||
// V2 SYSTEM: Smart button management with persistent state awareness
|
||
if (actionsEl && !['completed', 'failed', 'cancelled', 'post_processing'].includes(task.status)) {
|
||
// Check if we're in a cancelling state
|
||
if (isV2Task && uiState === 'cancelling') {
|
||
actionsEl.innerHTML = '<span style="color: #666;">Cancelling...</span>';
|
||
} else {
|
||
// Create V2 cancel button for all active tasks
|
||
const onclickHandler = isV2Task ? 'cancelTrackDownloadV2' : 'cancelTrackDownload';
|
||
actionsEl.innerHTML = `<button class="cancel-track-btn" title="Cancel this download" onclick="${onclickHandler}('${playlistId}', ${task.track_index})">×</button>`;
|
||
}
|
||
} else if (actionsEl && ['completed', 'failed', 'cancelled', 'post_processing'].includes(task.status)) {
|
||
actionsEl.innerHTML = '-'; // No actions available for terminal or processing states
|
||
}
|
||
});
|
||
|
||
// ENHANCED: Validate worker counts from server data
|
||
const serverActiveWorkers = data.active_count || 0;
|
||
const maxWorkers = data.max_concurrent || 3;
|
||
|
||
// V2 SYSTEM: Simplified worker counting - backend is authoritative
|
||
// Count active tasks, excluding locally cancelled legacy tasks only
|
||
const clientActiveWorkers = (data.tasks || []).filter(task => {
|
||
const row = document.querySelector(`tr[data-track-index="${task.track_index}"]`);
|
||
const isLegacyCancelled = row && row.dataset.locallyCancelled === 'true' && !row.dataset.useV2System;
|
||
return ['searching', 'downloading', 'queued'].includes(task.status) && !isLegacyCancelled;
|
||
}).length;
|
||
|
||
// Log discrepancies for debugging
|
||
if (serverActiveWorkers !== clientActiveWorkers) {
|
||
console.warn(`🔍 [Worker Validation] ${playlistId}: server reports ${serverActiveWorkers} active, client sees ${clientActiveWorkers} active tasks`);
|
||
|
||
// If server reports 0 but client sees active tasks, this might indicate ghost workers were fixed
|
||
if (serverActiveWorkers === 0 && clientActiveWorkers > 0) {
|
||
console.warn(`🚨 [Worker Validation] Server reports 0 workers but client sees ${clientActiveWorkers} active tasks - potential UI desync`);
|
||
}
|
||
}
|
||
|
||
console.debug(`📊 [Worker Status] ${playlistId}: ${serverActiveWorkers}/${maxWorkers} active workers, ${clientActiveWorkers} client-side active tasks`);
|
||
|
||
const totalFinished = completedCount + failedOrCancelledCount;
|
||
const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 0;
|
||
document.getElementById(`download-progress-fill-${playlistId}`).style.width = `${progressPercent}%`;
|
||
document.getElementById(`download-progress-text-${playlistId}`).textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`;
|
||
document.getElementById(`stat-downloaded-${playlistId}`).textContent = completedCount;
|
||
|
||
// FIXED: Only trigger completion logic when backend actually reports batch as complete
|
||
// Don't assume completion based on task counts - let backend determine when truly complete
|
||
if (data.phase === 'complete' || data.phase === 'error') {
|
||
// Enhanced check for background auto-processing for wishlist
|
||
const isWishlist = (playlistId === 'wishlist');
|
||
const isModalHidden = (process.modalElement && process.modalElement.style.display === 'none');
|
||
const isAutoInitiated = data.auto_initiated || false; // Server indicates if batch was auto-started
|
||
const isBackgroundWishlist = isWishlist && (isModalHidden || isAutoInitiated);
|
||
|
||
// Note: Auto-show logic removed - wishlist modal visibility managed by user interaction only
|
||
|
||
if (data.phase === 'cancelled') {
|
||
process.status = 'cancelled';
|
||
|
||
// Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on cancel
|
||
if (playlistId.startsWith('youtube_')) {
|
||
const urlHash = playlistId.replace('youtube_', '');
|
||
updateYouTubeCardPhase(urlHash, 'discovered');
|
||
}
|
||
|
||
showToast(`Process cancelled for ${process.playlist.name}.`, 'info');
|
||
} else if (data.phase === 'error') {
|
||
process.status = 'complete'; // Treat as complete to allow cleanup
|
||
updatePlaylistCardUI(playlistId); // Update card to show ready for review
|
||
|
||
// Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on error
|
||
if (playlistId.startsWith('youtube_')) {
|
||
const urlHash = playlistId.replace('youtube_', '');
|
||
updateYouTubeCardPhase(urlHash, 'discovered');
|
||
}
|
||
|
||
showToast(`Process for ${process.playlist.name} failed!`, 'error');
|
||
} else {
|
||
process.status = 'complete';
|
||
updatePlaylistCardUI(playlistId); // Update card to show ready for review
|
||
|
||
// Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
|
||
if (playlistId.startsWith('youtube_')) {
|
||
const urlHash = playlistId.replace('youtube_', '');
|
||
updateYouTubeCardPhase(urlHash, 'download_complete');
|
||
}
|
||
|
||
// Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist
|
||
if (playlistId.startsWith('tidal_')) {
|
||
const tidalPlaylistId = playlistId.replace('tidal_', '');
|
||
if (tidalPlaylistStates[tidalPlaylistId]) {
|
||
tidalPlaylistStates[tidalPlaylistId].phase = 'download_complete';
|
||
// Store the download process ID for potential modal rehydration
|
||
tidalPlaylistStates[tidalPlaylistId].download_process_id = process.batchId;
|
||
updateTidalCardPhase(tidalPlaylistId, 'download_complete');
|
||
console.log(`✅ [Status Complete] Updated Tidal playlist ${tidalPlaylistId} to download_complete phase`);
|
||
}
|
||
}
|
||
|
||
// Handle background wishlist processing completion specially
|
||
if (isBackgroundWishlist) {
|
||
console.log(`🎉 Background wishlist processing complete: ${completedCount} downloaded, ${failedOrCancelledCount} failed`);
|
||
|
||
// Reset modal to idle state to prevent "complete" phase disruption
|
||
setTimeout(() => {
|
||
resetWishlistModalToIdleState();
|
||
// Server-side auto-processing will handle next cycle automatically
|
||
}, 500);
|
||
|
||
return; // Skip normal completion handling
|
||
}
|
||
|
||
// Show completion summary with wishlist stats (matching sync.py behavior)
|
||
let completionMessage = `Process complete for ${process.playlist.name}!`;
|
||
let messageType = 'success';
|
||
|
||
// Check for wishlist summary from backend (added when failed/cancelled tracks are processed)
|
||
if (data.wishlist_summary) {
|
||
const summary = data.wishlist_summary;
|
||
completionMessage = `Download process complete! Downloaded: ${completedCount}, Failed/Cancelled: ${failedOrCancelledCount}.`;
|
||
|
||
if (summary.tracks_added > 0) {
|
||
completionMessage += ` Added ${summary.tracks_added} failed track${summary.tracks_added !== 1 ? 's' : ''} to wishlist for automatic retry.`;
|
||
} else if (summary.total_failed > 0) {
|
||
completionMessage += ` ${summary.total_failed} track${summary.total_failed !== 1 ? 's' : ''} could not be added to wishlist.`;
|
||
messageType = 'warning';
|
||
}
|
||
}
|
||
|
||
showToast(completionMessage, messageType);
|
||
}
|
||
|
||
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none';
|
||
|
||
// Mark process as complete and trigger cleanup check
|
||
process.status = 'complete';
|
||
updatePlaylistCardUI(playlistId);
|
||
|
||
// Check if any other processes still need polling
|
||
checkAndCleanupGlobalPolling();
|
||
}
|
||
}
|
||
}
|
||
|
||
function checkAndCleanupGlobalPolling() {
|
||
// Check if any processes still need polling
|
||
const hasActivePolling = Object.values(activeDownloadProcesses)
|
||
.some(p => p.batchId && p.status === 'running');
|
||
|
||
if (!hasActivePolling) {
|
||
console.debug('🧹 [Cleanup] No more active processes, continuing polling');
|
||
// Keep polling active - no need to stop
|
||
}
|
||
}
|
||
|
||
// LEGACY FUNCTION: Keep for backward compatibility, but now uses global polling
|
||
function startModalDownloadPolling(playlistId) {
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (!process || !process.batchId) return;
|
||
|
||
console.log(`🔄 [Legacy Polling] Starting polling for ${playlistId}, delegating to global poller`);
|
||
|
||
// Clear any existing individual poller (cleanup)
|
||
if (process.poller) {
|
||
clearInterval(process.poller);
|
||
process.poller = null;
|
||
}
|
||
|
||
// Mark process as running to be picked up by global poller
|
||
process.status = 'running';
|
||
|
||
// Start global polling if not already running
|
||
startGlobalDownloadPolling();
|
||
|
||
// Create dummy poller for backward compatibility with cleanup functions
|
||
ensureLegacyCompatibility(playlistId);
|
||
}
|
||
|
||
// For backward compatibility with cleanup functions that expect process.poller
|
||
// Creates a dummy poller that will be cleaned up by the existing cleanup logic
|
||
function createLegacyPoller(playlistId) {
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (!process) return;
|
||
|
||
// Create a dummy interval that just checks if the process is still active
|
||
// This ensures existing cleanup logic that calls clearInterval(process.poller) works
|
||
process.poller = setInterval(() => {
|
||
// This dummy poller doesn't do anything - global poller handles updates
|
||
if (!activeDownloadProcesses[playlistId] || process.status === 'complete') {
|
||
clearInterval(process.poller);
|
||
process.poller = null;
|
||
return;
|
||
}
|
||
}, 5000); // Very infrequent check, just for cleanup compatibility
|
||
}
|
||
|
||
// Call this to create the legacy poller after starting global polling
|
||
function ensureLegacyCompatibility(playlistId) {
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (process && !process.poller) {
|
||
createLegacyPoller(playlistId);
|
||
}
|
||
}
|
||
async function updateModalWithLiveDownloadProgress() {
|
||
try {
|
||
if (!currentDownloadBatchId) return;
|
||
|
||
// Fetch live download data from the downloads API
|
||
const response = await fetch('/api/downloads/status');
|
||
const downloadData = await response.json();
|
||
|
||
if (downloadData.error) return;
|
||
|
||
// Get all active and finished downloads
|
||
const allDownloads = {...(downloadData.active || {}), ...(downloadData.finished || {})};
|
||
|
||
// Update modal tracks that have active downloads
|
||
const modalRows = document.querySelectorAll('.download-missing-modal tr[data-track-index]');
|
||
|
||
for (const row of modalRows) {
|
||
const taskId = row.dataset.taskId;
|
||
if (!taskId) continue;
|
||
|
||
// Find corresponding download by checking if filename/title matches
|
||
const trackName = row.querySelector('.track-name')?.textContent?.trim();
|
||
if (!trackName) continue;
|
||
|
||
// Search for matching download
|
||
for (const [downloadId, downloadInfo] of Object.entries(allDownloads)) {
|
||
const downloadTitle = downloadInfo.filename ? downloadInfo.filename.split(/[\\/]/).pop() : '';
|
||
|
||
// Simple matching - could be improved with better logic
|
||
if (downloadTitle && trackName && (
|
||
downloadTitle.toLowerCase().includes(trackName.toLowerCase()) ||
|
||
trackName.toLowerCase().includes(downloadTitle.toLowerCase())
|
||
)) {
|
||
// Update the track with live download progress
|
||
const statusElement = row.querySelector('.track-download-status');
|
||
const progress = downloadInfo.percentComplete || 0;
|
||
const state = downloadInfo.state || '';
|
||
|
||
if (statusElement && state.includes('InProgress') && progress > 0) {
|
||
statusElement.textContent = `⏬ Downloading... ${Math.round(progress)}%`;
|
||
statusElement.className = 'track-download-status download-downloading';
|
||
} else if (statusElement && (state.includes('Completed') || state.includes('Succeeded'))) {
|
||
statusElement.textContent = '✅ Completed';
|
||
statusElement.className = 'track-download-status download-complete';
|
||
}
|
||
|
||
break; // Found a match, stop searching
|
||
}
|
||
}
|
||
}
|
||
|
||
} catch (error) {
|
||
// Silent fail - don't spam console during normal operation
|
||
}
|
||
}
|
||
|
||
async function cancelAllOperations(playlistId) {
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (!process) return;
|
||
|
||
// Prevent multiple cancel all operations
|
||
if (process.cancellingAll) {
|
||
console.log(`⚠️ Cancel All already in progress for ${playlistId}`);
|
||
return;
|
||
}
|
||
process.cancellingAll = true;
|
||
|
||
console.log(`🚫 Cancel All clicked for playlist ${playlistId} - closing modal and cleaning up server`);
|
||
|
||
showToast('Cancelling all operations and closing modal...', 'info');
|
||
|
||
// Mark process as complete immediately so polling stops
|
||
process.status = 'complete';
|
||
|
||
// Stop any active polling
|
||
if (process.poller) {
|
||
clearInterval(process.poller);
|
||
process.poller = null;
|
||
}
|
||
|
||
// Tell server to stop starting new downloads and clean up the batch
|
||
if (process.batchId) {
|
||
try {
|
||
// Cancel the batch (stops new downloads from starting)
|
||
const cancelResponse = await fetch(`/api/playlists/${process.batchId}/cancel_batch`, {
|
||
method: 'POST'
|
||
});
|
||
if (cancelResponse.ok) {
|
||
const cancelData = await cancelResponse.json();
|
||
console.log(`✅ Server stopped new downloads for batch ${process.batchId}`);
|
||
}
|
||
} catch (error) {
|
||
console.warn('Error during server batch cancel:', error);
|
||
}
|
||
}
|
||
|
||
// Close the modal immediately - this will handle cleanup
|
||
closeDownloadMissingModal(playlistId);
|
||
|
||
showToast('Modal closed. Active downloads will finish in background.', 'success');
|
||
}
|
||
|
||
function resetToInitialState() {
|
||
// Reset UI
|
||
document.getElementById('begin-analysis-btn').style.display = 'inline-block';
|
||
document.getElementById('start-downloads-btn').style.display = 'none';
|
||
document.getElementById('cancel-all-btn').style.display = 'none';
|
||
|
||
// Reset progress bars
|
||
document.getElementById('analysis-progress-fill').style.width = '0%';
|
||
document.getElementById('download-progress-fill').style.width = '0%';
|
||
document.getElementById('analysis-progress-text').textContent = 'Ready to start';
|
||
document.getElementById('download-progress-text').textContent = 'Waiting for analysis';
|
||
|
||
// Reset stats
|
||
document.getElementById('stat-found').textContent = '-';
|
||
document.getElementById('stat-missing').textContent = '-';
|
||
document.getElementById('stat-downloaded').textContent = '0';
|
||
|
||
// Reset track table
|
||
const tbody = document.getElementById('download-tracks-tbody');
|
||
if (tbody) {
|
||
const rows = tbody.querySelectorAll('tr');
|
||
rows.forEach((row, index) => {
|
||
const matchElement = row.querySelector('.track-match-status');
|
||
const downloadElement = row.querySelector('.track-download-status');
|
||
const actionsElement = row.querySelector('.track-actions');
|
||
|
||
if (matchElement) {
|
||
matchElement.textContent = '🔍 Pending';
|
||
matchElement.className = 'track-match-status match-checking';
|
||
}
|
||
if (downloadElement) {
|
||
downloadElement.textContent = '-';
|
||
downloadElement.className = 'track-download-status';
|
||
}
|
||
if (actionsElement) {
|
||
actionsElement.textContent = '-';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Reset state
|
||
activeAnalysisTaskId = null;
|
||
analysisResults = [];
|
||
missingTracks = [];
|
||
}
|
||
|
||
// ===============================
|
||
// NEW ATOMIC CANCEL SYSTEM V2
|
||
// ===============================
|
||
|
||
async function cancelTrackDownloadV2(playlistId, trackIndex) {
|
||
/**
|
||
* NEW ATOMIC CANCEL SYSTEM V2
|
||
*
|
||
* - No optimistic UI updates
|
||
* - Single API call handles everything atomically
|
||
* - Backend is single source of truth for all state
|
||
* - No race conditions or dual state management
|
||
*/
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (!process) {
|
||
console.warn(`❌ [Cancel V2] No process found for playlist: ${playlistId}`);
|
||
return;
|
||
}
|
||
|
||
const row = document.querySelector(`#download-missing-modal-${playlistId} tr[data-track-index="${trackIndex}"]`);
|
||
if (!row) {
|
||
console.warn(`❌ [Cancel V2] No row found for track index: ${trackIndex}`);
|
||
return;
|
||
}
|
||
|
||
// Check if already in cancelling state
|
||
const statusEl = document.getElementById(`download-${playlistId}-${trackIndex}`);
|
||
const currentStatus = statusEl ? statusEl.textContent : '';
|
||
|
||
if (currentStatus.includes('Cancelling') || currentStatus.includes('Cancelled')) {
|
||
console.log(`⚠️ [Cancel V2] Task already being cancelled or cancelled: ${currentStatus}`);
|
||
return;
|
||
}
|
||
|
||
console.log(`🎯 [Cancel V2] Starting atomic cancel: playlist=${playlistId}, track=${trackIndex}`);
|
||
|
||
// V2 SYSTEM: Set temporary UI state - will be confirmed by server
|
||
row.dataset.uiState = 'cancelling';
|
||
|
||
// Show loading state only - no optimistic "cancelled" state
|
||
if (statusEl) {
|
||
statusEl.textContent = '🔄 Cancelling...';
|
||
}
|
||
|
||
// Disable the cancel button to prevent double-clicks
|
||
const actionsEl = document.getElementById(`actions-${playlistId}-${trackIndex}`);
|
||
if (actionsEl) {
|
||
actionsEl.innerHTML = '<span style="color: #666;">Cancelling...</span>';
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/downloads/cancel_task_v2', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
playlist_id: playlistId,
|
||
track_index: trackIndex
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
console.log(`✅ [Cancel V2] Successfully cancelled: ${data.task_info.track_name}`);
|
||
showToast(`Cancelled "${data.task_info.track_name}" and added to wishlist.`, 'success');
|
||
|
||
// Let the status polling system update the UI with server truth
|
||
// No manual UI updates - backend is authoritative
|
||
|
||
} else {
|
||
console.error(`❌ [Cancel V2] Cancel failed: ${data.error}`);
|
||
showToast(`Cancel failed: ${data.error}`, 'error');
|
||
|
||
// Reset UI to previous state on failure
|
||
row.dataset.uiState = 'normal'; // Reset UI state
|
||
if (statusEl) {
|
||
statusEl.textContent = '❌ Cancel Failed';
|
||
}
|
||
if (actionsEl) {
|
||
actionsEl.innerHTML = `<button class="cancel-track-btn" title="Cancel this download" onclick="cancelTrackDownloadV2('${playlistId}', ${trackIndex})">×</button>`;
|
||
}
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`❌ [Cancel V2] Network/API error:`, error);
|
||
showToast(`Cancel request failed: ${error.message}`, 'error');
|
||
|
||
// Reset UI on network error
|
||
row.dataset.uiState = 'normal'; // Reset UI state
|
||
if (statusEl) {
|
||
statusEl.textContent = '❌ Cancel Failed';
|
||
}
|
||
if (actionsEl) {
|
||
actionsEl.innerHTML = `<button class="cancel-track-btn" title="Cancel this download" onclick="cancelTrackDownloadV2('${playlistId}', ${trackIndex})">×</button>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// LEGACY CANCEL SYSTEM (OLD)
|
||
// ===============================
|
||
|
||
async function cancelTrackDownload(playlistId, trackIndex) {
|
||
const process = activeDownloadProcesses[playlistId];
|
||
if (!process) return;
|
||
|
||
const row = document.querySelector(`#download-missing-modal-${playlistId} tr[data-track-index="${trackIndex}"]`);
|
||
if (!row) return;
|
||
|
||
// Prevent double cancellation
|
||
if (row.dataset.locallyCancelled === 'true') {
|
||
return; // Already cancelled locally
|
||
}
|
||
|
||
const taskId = row.dataset.taskId;
|
||
if (!taskId) {
|
||
showToast('Task not started yet, cannot cancel.', 'warning');
|
||
return;
|
||
}
|
||
|
||
// UI update for immediate feedback - mark as cancelled FIRST to prevent race conditions
|
||
row.dataset.locallyCancelled = 'true';
|
||
document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '🚫 Cancelling...';
|
||
document.getElementById(`actions-${playlistId}-${trackIndex}`).innerHTML = '-';
|
||
|
||
try {
|
||
const response = await fetch('/api/downloads/cancel_task', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ task_id: taskId })
|
||
});
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
// Update final UI state after successful cancellation
|
||
document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '🚫 Cancelled';
|
||
showToast('Download cancelled and added to wishlist.', 'info');
|
||
} else {
|
||
throw new Error(data.error);
|
||
}
|
||
} catch (error) {
|
||
// Reset UI state if cancellation failed
|
||
row.dataset.locallyCancelled = 'false';
|
||
document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '❌ Cancel Failed';
|
||
showToast(`Could not cancel task: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// Find and REPLACE the old startPlaylistSyncFromModal function
|
||
async function startPlaylistSync(playlistId) {
|
||
const startTime = Date.now();
|
||
console.log(`🚀 [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId}`);
|
||
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
|
||
if (!playlist) {
|
||
console.error(`❌ Could not find playlist data for ID: ${playlistId}`);
|
||
showToast('Could not find playlist data.', 'error');
|
||
return;
|
||
}
|
||
console.log(`✅ Found playlist: ${playlist.name} with ${playlist.track_count || 'unknown'} tracks`);
|
||
|
||
// Ensure we have the full track list before starting
|
||
let tracks = playlistTrackCache[playlistId];
|
||
if (!tracks) {
|
||
const trackFetchStart = Date.now();
|
||
console.log(`🔄 [${new Date().toTimeString().split(' ')[0]}] Cache miss - fetching tracks for playlist ${playlistId}`);
|
||
try {
|
||
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
|
||
const fullPlaylist = await response.json();
|
||
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
|
||
tracks = fullPlaylist.tracks;
|
||
playlistTrackCache[playlistId] = tracks; // Cache it
|
||
const trackFetchTime = Date.now() - trackFetchStart;
|
||
console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Fetched and cached ${tracks.length} tracks (took ${trackFetchTime}ms)`);
|
||
} catch (error) {
|
||
console.error(`❌ Failed to fetch tracks:`, error);
|
||
showToast(`Failed to fetch tracks for sync: ${error.message}`, 'error');
|
||
return;
|
||
}
|
||
} else {
|
||
console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Using cached tracks: ${tracks.length} tracks`);
|
||
}
|
||
|
||
// DON'T close the modal - let it show live progress like the GUI
|
||
|
||
try {
|
||
const syncStartTime = Date.now();
|
||
console.log(`🔄 [${new Date().toTimeString().split(' ')[0]}] Making API call to /api/sync/start with ${tracks.length} tracks`);
|
||
const response = await fetch('/api/sync/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
playlist_id: playlist.id,
|
||
playlist_name: playlist.name,
|
||
tracks: tracks // Send the full track list
|
||
})
|
||
});
|
||
|
||
const syncRequestTime = Date.now() - syncStartTime;
|
||
console.log(`📡 [${new Date().toTimeString().split(' ')[0]}] API response status: ${response.status} (took ${syncRequestTime}ms)`);
|
||
const data = await response.json();
|
||
console.log(`📡 [${new Date().toTimeString().split(' ')[0]}] API response data:`, data);
|
||
|
||
if (!data.success) throw new Error(data.error);
|
||
|
||
const totalTime = Date.now() - startTime;
|
||
console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Sync started successfully for "${playlist.name}" (total time: ${totalTime}ms)`);
|
||
showToast(`Sync started for "${playlist.name}"`, 'success');
|
||
|
||
// Show initial sync state in modal if open
|
||
const modal = document.getElementById('playlist-details-modal');
|
||
if (modal && modal.style.display !== 'none') {
|
||
const statusDisplay = document.getElementById(`modal-sync-status-${playlist.id}`);
|
||
if (statusDisplay) {
|
||
statusDisplay.style.display = 'flex';
|
||
console.log(`📊 [${new Date().toTimeString().split(' ')[0]}] Showing modal sync status for ${playlist.id}`);
|
||
}
|
||
}
|
||
|
||
updateCardToSyncing(playlist.id, 0); // Initial state
|
||
startSyncPolling(playlist.id);
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Failed to start sync:`, error);
|
||
showToast(`Failed to start sync: ${error.message}`, 'error');
|
||
updateCardToDefault(playlist.id);
|
||
}
|
||
}
|
||
|
||
// Add these new helper functions to script.js
|
||
|
||
function startSyncPolling(playlistId) {
|
||
// Clear any existing poller for this playlist
|
||
if (activeSyncPollers[playlistId]) {
|
||
clearInterval(activeSyncPollers[playlistId]);
|
||
}
|
||
|
||
// Start a new poller that checks every 2 seconds
|
||
console.log(`🔄 Starting sync polling for playlist: ${playlistId}`);
|
||
activeSyncPollers[playlistId] = setInterval(async () => {
|
||
try {
|
||
console.log(`📊 Polling sync status for: ${playlistId}`);
|
||
const response = await fetch(`/api/sync/status/${playlistId}`);
|
||
const state = await response.json();
|
||
console.log(`📊 Poll response:`, state);
|
||
|
||
if (state.status === 'syncing') {
|
||
const progress = state.progress;
|
||
console.log(`📊 Sync progress:`, progress);
|
||
console.log(` 📊 Progress values: ${progress.progress}% | Total: ${progress.total_tracks} | Matched: ${progress.matched_tracks} | Failed: ${progress.failed_tracks}`);
|
||
console.log(` 📊 Current step: "${progress.current_step}" | Current track: "${progress.current_track}"`);
|
||
|
||
// Use the actual progress percentage from the sync service
|
||
updateCardToSyncing(playlistId, progress.progress, progress);
|
||
// Also update the modal if it's open
|
||
updateModalSyncProgress(playlistId, progress);
|
||
} else if (state.status === 'finished' || state.status === 'error' || state.status === 'cancelled') {
|
||
console.log(`🏁 Sync completed with status: ${state.status}`);
|
||
stopSyncPolling(playlistId);
|
||
updateCardToDefault(playlistId, state);
|
||
// Also update the modal if it's open
|
||
closePlaylistDetailsModal(); // Close modal on completion/error
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ Error polling sync status for ${playlistId}:`, error);
|
||
stopSyncPolling(playlistId);
|
||
updateCardToDefault(playlistId, { status: 'error', error: 'Polling failed' });
|
||
}
|
||
}, 2000); // Poll every 2 seconds
|
||
updateRefreshButtonState();
|
||
}
|
||
|
||
function stopSyncPolling(playlistId) {
|
||
if (activeSyncPollers[playlistId]) {
|
||
clearInterval(activeSyncPollers[playlistId]);
|
||
delete activeSyncPollers[playlistId];
|
||
}
|
||
updateRefreshButtonState();
|
||
}
|
||
|
||
// Sequential Sync Functions
|
||
function startSequentialSync() {
|
||
// Initialize manager if needed
|
||
if (!sequentialSyncManager) {
|
||
sequentialSyncManager = new SequentialSyncManager();
|
||
}
|
||
|
||
// Check if already running - if so, cancel
|
||
if (sequentialSyncManager.isRunning) {
|
||
sequentialSyncManager.cancel();
|
||
return;
|
||
}
|
||
|
||
// Validate selection
|
||
if (selectedPlaylists.size === 0) {
|
||
showToast('No playlists selected for sync', 'error');
|
||
return;
|
||
}
|
||
|
||
// Get playlist order from DOM to maintain display order
|
||
const playlistCards = document.querySelectorAll('.playlist-card');
|
||
const orderedPlaylistIds = [];
|
||
|
||
playlistCards.forEach(card => {
|
||
const playlistId = card.dataset.playlistId;
|
||
if (selectedPlaylists.has(playlistId)) {
|
||
orderedPlaylistIds.push(playlistId);
|
||
}
|
||
});
|
||
|
||
console.log(`🚀 Starting sequential sync for ${orderedPlaylistIds.length} playlists`);
|
||
|
||
// Start sequential sync
|
||
sequentialSyncManager.start(orderedPlaylistIds);
|
||
|
||
// Disable playlist selection during sync
|
||
disablePlaylistSelection(true);
|
||
}
|
||
|
||
function disablePlaylistSelection(disabled) {
|
||
const checkboxes = document.querySelectorAll('.playlist-checkbox');
|
||
checkboxes.forEach(checkbox => {
|
||
checkbox.disabled = disabled;
|
||
});
|
||
}
|
||
|
||
function hasActiveOperations() {
|
||
const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0;
|
||
// Only check non-wishlist download processes for sync page refresh button
|
||
const hasActiveDownloads = Object.entries(activeDownloadProcesses)
|
||
.filter(([playlistId, process]) => playlistId !== 'wishlist') // Exclude wishlist
|
||
.some(([_, process]) => process.status === 'running');
|
||
const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning;
|
||
return hasActiveSyncs || hasActiveDownloads || hasSequentialSync;
|
||
}
|
||
|
||
|
||
function updateRefreshButtonState() {
|
||
const refreshBtn = document.getElementById('spotify-refresh-btn');
|
||
if (!refreshBtn) return;
|
||
|
||
if (hasActiveOperations()) {
|
||
refreshBtn.disabled = true;
|
||
// Provide context-specific text
|
||
const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0;
|
||
const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning;
|
||
if (hasActiveSyncs || hasSequentialSync) {
|
||
refreshBtn.textContent = '🔄 Syncing...';
|
||
} else {
|
||
refreshBtn.textContent = '📥 Downloading...';
|
||
}
|
||
} else {
|
||
refreshBtn.disabled = false;
|
||
refreshBtn.textContent = '🔄 Refresh';
|
||
}
|
||
}
|
||
|
||
function updateCardToSyncing(playlistId, percent, progress = null) {
|
||
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
|
||
if (!card) return;
|
||
|
||
const progressBar = card.querySelector('.sync-progress-indicator');
|
||
progressBar.style.display = 'block';
|
||
|
||
let progressText = 'Starting...';
|
||
let actualPercent = percent || 0;
|
||
|
||
if (progress) {
|
||
// Create detailed progress text like the GUI
|
||
const matched = progress.matched_tracks || 0;
|
||
const failed = progress.failed_tracks || 0;
|
||
const total = progress.total_tracks || 0;
|
||
const currentStep = progress.current_step || 'Processing';
|
||
|
||
// Calculate actual progress as processed/total, not just successful/total
|
||
if (total > 0) {
|
||
const processed = matched + failed;
|
||
actualPercent = Math.round((processed / total) * 100);
|
||
progressText = `${currentStep}: ${processed}/${total} (${matched} matched, ${failed} failed)`;
|
||
} else {
|
||
progressText = currentStep;
|
||
}
|
||
|
||
// If there's a current track being processed, show it
|
||
if (progress.current_track) {
|
||
progressText += ` - ${progress.current_track}`;
|
||
}
|
||
}
|
||
|
||
// Build live status counter HTML (same as modal)
|
||
let statusCounterHTML = '';
|
||
if (progress && progress.total_tracks > 0) {
|
||
const matched = progress.matched_tracks || 0;
|
||
const failed = progress.failed_tracks || 0;
|
||
const total = progress.total_tracks || 0;
|
||
const processed = matched + failed;
|
||
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||
|
||
statusCounterHTML = `
|
||
<div class="playlist-card-sync-status">
|
||
<span class="sync-stat total-tracks">♪ ${total}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ ${matched}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ ${failed}</span>
|
||
<span class="sync-stat percentage">(${percentage}%)</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
progressBar.innerHTML = `
|
||
${statusCounterHTML}
|
||
<div class="progress-bar-sync">
|
||
<div class="progress-fill-sync" style="width: ${actualPercent}%;"></div>
|
||
</div>
|
||
<div class="progress-text-sync">${progressText}</div>
|
||
`;
|
||
}
|
||
|
||
function updateCardToDefault(playlistId, finalState = null) {
|
||
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
|
||
if (!card) return;
|
||
|
||
const progressBar = card.querySelector('.sync-progress-indicator');
|
||
progressBar.style.display = 'none';
|
||
progressBar.innerHTML = '';
|
||
|
||
const statusEl = card.querySelector('.playlist-card-status');
|
||
if (finalState) {
|
||
if (finalState.status === 'finished') {
|
||
statusEl.textContent = `Synced: Just now`;
|
||
statusEl.className = 'playlist-card-status status-synced';
|
||
showToast(`Sync complete for "${card.querySelector('.playlist-card-name').textContent}"`, 'success');
|
||
} else {
|
||
statusEl.textContent = `Sync Failed`;
|
||
statusEl.className = 'playlist-card-status status-needs-sync'; // Or a new error class
|
||
showToast(`Sync failed: ${finalState.error || 'Unknown error'}`, 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update the modal's sync progress display (matches GUI functionality)
|
||
function updateModalSyncProgress(playlistId, progress) {
|
||
const modal = document.getElementById('playlist-details-modal');
|
||
if (modal && modal.style.display !== 'none') {
|
||
console.log(`📊 Updating modal sync progress for ${playlistId}:`, progress);
|
||
|
||
// Show sync status display
|
||
const statusDisplay = document.getElementById(`modal-sync-status-${playlistId}`);
|
||
if (statusDisplay) {
|
||
statusDisplay.style.display = 'flex';
|
||
|
||
// Update counters (matching GUI exactly)
|
||
const totalEl = document.getElementById(`modal-total-${playlistId}`);
|
||
const matchedEl = document.getElementById(`modal-matched-${playlistId}`);
|
||
const failedEl = document.getElementById(`modal-failed-${playlistId}`);
|
||
const percentageEl = document.getElementById(`modal-percentage-${playlistId}`);
|
||
|
||
const total = progress.total_tracks || 0;
|
||
const matched = progress.matched_tracks || 0;
|
||
const failed = progress.failed_tracks || 0;
|
||
|
||
if (totalEl) totalEl.textContent = total;
|
||
if (matchedEl) matchedEl.textContent = matched;
|
||
if (failedEl) failedEl.textContent = failed;
|
||
|
||
// Calculate percentage like GUI
|
||
if (total > 0) {
|
||
const processed = matched + failed;
|
||
const percentage = Math.round((processed / total) * 100);
|
||
if (percentageEl) percentageEl.textContent = percentage;
|
||
}
|
||
|
||
console.log(`📊 Modal updated: ♪ ${total} / ✓ ${matched} / ✗ ${failed} (${Math.round((matched + failed) / total * 100)}%)`);
|
||
} else {
|
||
console.warn(`❌ Modal sync status display not found for ${playlistId}`);
|
||
}
|
||
} else {
|
||
console.log(`📊 Modal not open for ${playlistId}, skipping update`);
|
||
}
|
||
}
|
||
|
||
|
||
// Download tracking state management - matching GUI functionality
|
||
let activeDownloads = {};
|
||
let finishedDownloads = {};
|
||
let downloadStatusInterval = null;
|
||
let isDownloadPollingActive = false;
|
||
|
||
async function loadDownloadsData() {
|
||
// Downloads page loads search results dynamically
|
||
console.log('Downloads page loaded');
|
||
|
||
// Connect downloads search button
|
||
const searchButton = document.getElementById('downloads-search-btn');
|
||
const searchInput = document.getElementById('downloads-search-input');
|
||
const clearButton = document.querySelector('.controls-panel__clear-btn');
|
||
|
||
if (searchButton && searchInput) {
|
||
searchButton.addEventListener('click', performDownloadsSearch);
|
||
searchInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') performDownloadsSearch();
|
||
});
|
||
}
|
||
|
||
if (clearButton) {
|
||
clearButton.addEventListener('click', clearFinishedDownloads);
|
||
}
|
||
|
||
// Start sophisticated polling system (1-second interval like GUI)
|
||
startDownloadPolling();
|
||
|
||
// Initialize tab management
|
||
initializeDownloadTabs();
|
||
}
|
||
|
||
function startDownloadPolling() {
|
||
if (isDownloadPollingActive) return;
|
||
|
||
console.log('Starting download status polling (1-second interval)');
|
||
isDownloadPollingActive = true;
|
||
|
||
// Initial call
|
||
updateDownloadQueues();
|
||
|
||
// Start 1-second polling (matching GUI's 1000ms timer)
|
||
downloadStatusInterval = setInterval(updateDownloadQueues, 1000);
|
||
}
|
||
|
||
function stopDownloadPolling() {
|
||
if (downloadStatusInterval) {
|
||
clearInterval(downloadStatusInterval);
|
||
downloadStatusInterval = null;
|
||
}
|
||
isDownloadPollingActive = false;
|
||
console.log('Stopped download status polling');
|
||
}
|
||
|
||
async function updateDownloadQueues() {
|
||
try {
|
||
const response = await fetch('/api/downloads/status');
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
console.error("Error fetching download status:", data.error);
|
||
return;
|
||
}
|
||
|
||
const newActive = {};
|
||
const newFinished = {};
|
||
|
||
// Terminal states matching GUI logic
|
||
const terminalStates = ['Completed', 'Succeeded', 'Cancelled', 'Canceled', 'Failed', 'Errored'];
|
||
|
||
// Process transfers exactly like GUI
|
||
data.transfers.forEach(item => {
|
||
const isTerminal = terminalStates.some(state =>
|
||
item.state && item.state.includes(state)
|
||
);
|
||
|
||
if (isTerminal) {
|
||
newFinished[item.id] = item;
|
||
} else {
|
||
newActive[item.id] = item;
|
||
}
|
||
});
|
||
|
||
// Update global state
|
||
activeDownloads = newActive;
|
||
finishedDownloads = newFinished;
|
||
|
||
// Render both queues
|
||
renderQueue('active-queue', activeDownloads, true);
|
||
renderQueue('finished-queue', finishedDownloads, false);
|
||
|
||
// Update tab counts
|
||
updateTabCounts();
|
||
|
||
// Update stats in the side panel
|
||
updateDownloadStats();
|
||
|
||
} catch (error) {
|
||
// Only log errors occasionally to avoid console spam
|
||
if (Math.random() < 0.1) {
|
||
console.error("Failed to update download queues:", error);
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderQueue(containerId, downloads, isActiveQueue) {
|
||
const container = document.getElementById(containerId);
|
||
if (!container) return;
|
||
|
||
const downloadIds = Object.keys(downloads);
|
||
|
||
if (downloadIds.length === 0) {
|
||
container.innerHTML = `<div class="download-queue__empty-message">${isActiveQueue ? 'No active downloads.' : 'No finished downloads.'}</div>`;
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
for (const id of downloadIds) {
|
||
const item = downloads[id];
|
||
const title = item.filename ? item.filename.split(/[\\/]/).pop() : 'Unknown File';
|
||
const progress = item.percentComplete || 0;
|
||
const bytesTransferred = item.bytesTransferred || 0;
|
||
const totalBytes = item.size || 0;
|
||
const speed = item.averageSpeed || 0;
|
||
|
||
// Format file size
|
||
const formatSize = (bytes) => {
|
||
if (!bytes) return 'Unknown size';
|
||
const units = ['B', 'KB', 'MB', 'GB'];
|
||
let size = bytes;
|
||
let unitIndex = 0;
|
||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||
size /= 1024;
|
||
unitIndex++;
|
||
}
|
||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||
};
|
||
|
||
// Format speed
|
||
const formatSpeed = (bytesPerSecond) => {
|
||
if (!bytesPerSecond || bytesPerSecond <= 0) return '';
|
||
return `${formatSize(bytesPerSecond)}/s`;
|
||
};
|
||
|
||
let actionButtonHTML = '';
|
||
if (isActiveQueue) {
|
||
// Active items get progress bar and cancel button
|
||
actionButtonHTML = `
|
||
<div class="download-item__progress-container">
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" style="width: ${progress}%;"></div>
|
||
</div>
|
||
<div class="progress-text">
|
||
${item.state} - ${progress.toFixed(1)}%
|
||
${speed > 0 ? `• ${formatSpeed(speed)}` : ''}
|
||
${totalBytes > 0 ? `• ${formatSize(bytesTransferred)} / ${formatSize(totalBytes)}` : ''}
|
||
</div>
|
||
</div>
|
||
<button class="download-item__cancel-btn" onclick="cancelDownloadItem('${item.id}', '${item.username}')">✕ Cancel</button>
|
||
`;
|
||
} else {
|
||
// Finished items get status and open button
|
||
let statusClass = '';
|
||
if (item.state.includes('Cancelled')) statusClass = 'status--cancelled';
|
||
else if (item.state.includes('Failed') || item.state.includes('Errored')) statusClass = 'status--failed';
|
||
else if (item.state.includes('Completed') || item.state.includes('Succeeded')) statusClass = 'status--completed';
|
||
|
||
actionButtonHTML = `
|
||
<div class="download-item__status-container">
|
||
<span class="download-item__status-text ${statusClass}">${item.state}</span>
|
||
</div>
|
||
<button class="download-item__open-btn" title="Cannot open folder from web browser" disabled>📁 Open</button>
|
||
`;
|
||
}
|
||
|
||
html += `
|
||
<div class="download-item" data-id="${item.id}">
|
||
<div class="download-item__header">
|
||
<div class="download-item__title" title="${title}">${title}</div>
|
||
<div class="download-item__uploader" title="from ${item.username}">from ${item.username}</div>
|
||
</div>
|
||
<div class="download-item__content">
|
||
${actionButtonHTML}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function updateTabCounts() {
|
||
const activeCount = Object.keys(activeDownloads).length;
|
||
const finishedCount = Object.keys(finishedDownloads).length;
|
||
|
||
const activeTabBtn = document.querySelector('.tab-btn[data-tab="active-queue"]');
|
||
const finishedTabBtn = document.querySelector('.tab-btn[data-tab="finished-queue"]');
|
||
|
||
if (activeTabBtn) activeTabBtn.textContent = `Download Queue (${activeCount})`;
|
||
if (finishedTabBtn) finishedTabBtn.textContent = `Finished (${finishedCount})`;
|
||
}
|
||
|
||
function updateDownloadStats() {
|
||
const activeCount = Object.keys(activeDownloads).length;
|
||
const finishedCount = Object.keys(finishedDownloads).length;
|
||
|
||
const activeLabel = document.getElementById('active-downloads-label');
|
||
const finishedLabel = document.getElementById('finished-downloads-label');
|
||
|
||
if (activeLabel) activeLabel.textContent = `• Active Downloads: ${activeCount}`;
|
||
if (finishedLabel) finishedLabel.textContent = `• Finished Downloads: ${finishedCount}`;
|
||
}
|
||
|
||
function initializeDownloadTabs() {
|
||
const tabButtons = document.querySelectorAll('.tab-btn');
|
||
tabButtons.forEach(btn => {
|
||
btn.addEventListener('click', () => switchDownloadTab(btn));
|
||
});
|
||
}
|
||
|
||
function switchDownloadTab(button) {
|
||
const targetTabId = button.getAttribute('data-tab');
|
||
|
||
// Update buttons
|
||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||
button.classList.add('active');
|
||
|
||
// Update content panes
|
||
document.querySelectorAll('.download-queue').forEach(queue => queue.classList.remove('active'));
|
||
const targetQueue = document.getElementById(targetTabId);
|
||
if (targetQueue) targetQueue.classList.add('active');
|
||
}
|
||
|
||
async function cancelDownloadItem(downloadId, username) {
|
||
try {
|
||
const response = await fetch('/api/downloads/cancel', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ download_id: downloadId, username: username })
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showToast('Download cancelled', 'success');
|
||
} else {
|
||
showToast(`Failed to cancel: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error cancelling download:', error);
|
||
showToast('Error sending cancel request', 'error');
|
||
}
|
||
}
|
||
|
||
async function clearFinishedDownloads() {
|
||
const finishedCount = Object.keys(finishedDownloads).length;
|
||
if (finishedCount === 0) {
|
||
showToast('No finished downloads to clear', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/downloads/clear-finished', {
|
||
method: 'POST'
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showToast('Finished downloads cleared', 'success');
|
||
} else {
|
||
showToast(`Failed to clear: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error clearing finished downloads:', error);
|
||
showToast('Error sending clear request', 'error');
|
||
}
|
||
}
|
||
|
||
// REPLACE the old performDownloadsSearch function with this new one.
|
||
async function performDownloadsSearch() {
|
||
const query = document.getElementById('downloads-search-input').value.trim();
|
||
if (!query) {
|
||
showToast('Please enter a search term', 'error');
|
||
return;
|
||
}
|
||
|
||
// --- UI Element References ---
|
||
const searchInput = document.getElementById('downloads-search-input');
|
||
const searchButton = document.getElementById('downloads-search-btn');
|
||
const cancelButton = document.getElementById('downloads-cancel-btn');
|
||
const statusText = document.getElementById('search-status-text');
|
||
const spinner = document.querySelector('.spinner-animation');
|
||
const dots = document.querySelector('.dots-animation');
|
||
|
||
// --- Start a new AbortController for this search ---
|
||
searchAbortController = new AbortController();
|
||
|
||
try {
|
||
// --- 1. Update UI to "Searching" State ---
|
||
searchInput.disabled = true;
|
||
searchButton.disabled = true;
|
||
cancelButton.classList.remove('hidden');
|
||
spinner.classList.remove('hidden');
|
||
dots.classList.remove('hidden');
|
||
statusText.textContent = `Searching for '${query}'...`;
|
||
displayDownloadsResults([]); // Clear previous results
|
||
|
||
// --- 2. Perform the Fetch Request ---
|
||
const response = await fetch('/api/search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ query }),
|
||
signal: searchAbortController.signal // Link fetch to the AbortController
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
const results = data.results || [];
|
||
allSearchResults = results;
|
||
resetFilters();
|
||
applyFiltersAndSort();
|
||
|
||
// --- 3. Update UI with Success State ---
|
||
if (results.length === 0) {
|
||
statusText.textContent = `No results found for '${query}'`;
|
||
showToast('No results found', 'error');
|
||
} else {
|
||
document.getElementById('filters-container').classList.remove('hidden');
|
||
|
||
// Count albums and singles like the GUI app
|
||
let totalAlbums = 0;
|
||
let totalTracks = 0;
|
||
|
||
results.forEach(result => {
|
||
if (result.result_type === 'album') {
|
||
totalAlbums++;
|
||
} else {
|
||
totalTracks++;
|
||
}
|
||
});
|
||
|
||
statusText.textContent = `✨ Found ${results.length} results • ${totalAlbums} albums, ${totalTracks} singles`;
|
||
showToast(`Found ${results.length} results`, 'success');
|
||
}
|
||
|
||
} catch (error) {
|
||
// --- 4. Handle Errors, Including Cancellation ---
|
||
if (error.name === 'AbortError') {
|
||
// This specific error is thrown when the user clicks "Cancel"
|
||
statusText.textContent = 'Search was cancelled.';
|
||
showToast('Search cancelled', 'info');
|
||
displayDownloadsResults([]); // Clear any partial results
|
||
} else {
|
||
console.error('Search failed:', error);
|
||
statusText.textContent = `Search failed: ${error.message}`;
|
||
showToast('Search failed', 'error');
|
||
}
|
||
} finally {
|
||
// --- 5. Clean Up UI Regardless of Outcome ---
|
||
searchInput.disabled = false;
|
||
searchButton.disabled = false;
|
||
cancelButton.classList.add('hidden');
|
||
spinner.classList.add('hidden');
|
||
dots.classList.add('hidden');
|
||
searchAbortController = null; // Clear the controller
|
||
}
|
||
}
|
||
|
||
function displayDownloadsResults(results) {
|
||
const resultsArea = document.getElementById('search-results-area');
|
||
if (!resultsArea) return;
|
||
|
||
if (!results.length) {
|
||
resultsArea.innerHTML = '<div class="search-results-placeholder"><p>No search results found.</p></div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
results.forEach((result, index) => {
|
||
const isAlbum = result.result_type === 'album';
|
||
|
||
if (isAlbum) {
|
||
const trackCount = result.tracks ? result.tracks.length : 0;
|
||
const totalSize = result.total_size ? `${(result.total_size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
|
||
|
||
// Generate individual track items
|
||
let trackListHtml = '';
|
||
if (result.tracks && result.tracks.length > 0) {
|
||
result.tracks.forEach((track, trackIndex) => {
|
||
const trackSize = track.size ? `${(track.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
|
||
const trackBitrate = track.bitrate ? `${track.bitrate}kbps` : '';
|
||
trackListHtml += `
|
||
<div class="track-item">
|
||
<div class="track-item-info">
|
||
<div class="track-item-title">${escapeHtml(track.title || `Track ${trackIndex + 1}`)}</div>
|
||
<div class="track-item-details">
|
||
${track.track_number ? `${track.track_number}. ` : ''}${escapeHtml(track.artist || result.artist || 'Unknown Artist')} • ${trackSize} • ${escapeHtml(track.quality || 'Unknown')} ${trackBitrate}
|
||
</div>
|
||
</div>
|
||
<div class="track-item-actions">
|
||
<button onclick="streamAlbumTrack(${index}, ${trackIndex})" class="track-stream-btn">Stream ▶</button>
|
||
<button onclick="downloadAlbumTrack(${index}, ${trackIndex})" class="track-download-btn">Download ⬇</button>
|
||
<button onclick="matchedDownloadAlbumTrack(${index}, ${trackIndex})" class="track-matched-btn" title="Matched Download">Matched Download 🎯</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
html += `
|
||
<div class="album-result-card" data-album-index="${index}">
|
||
<div class="album-card-header" onclick="toggleAlbumExpansion(${index})">
|
||
<div class="album-expand-indicator">▶</div>
|
||
<div class="album-icon">💿</div>
|
||
<div class="album-info">
|
||
<div class="album-title">${escapeHtml(result.album_title || result.title || 'Unknown Album')}</div>
|
||
<div class="album-artist">by ${escapeHtml(result.artist || 'Unknown Artist')}</div>
|
||
<div class="album-details">
|
||
${trackCount} tracks • ${totalSize} • ${escapeHtml(result.quality || 'Mixed')}
|
||
</div>
|
||
<div class="album-uploader">Shared by ${escapeHtml(result.username || 'Unknown')}</div>
|
||
</div>
|
||
<div class="album-actions" onclick="event.stopPropagation()">
|
||
<button onclick="downloadAlbum(${index})" class="album-download-btn">⬇ Download Album</button>
|
||
<button onclick="matchedDownloadAlbum(${index})" class="album-matched-btn" title="Matched Album Download">Matched Album🎯</button>
|
||
</div>
|
||
</div>
|
||
<div class="album-track-list" style="display: none;">
|
||
${trackListHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
const sizeText = result.size ? `${(result.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
|
||
const bitrateText = result.bitrate ? `${result.bitrate}kbps` : '';
|
||
html += `
|
||
<div class="track-result-card">
|
||
<div class="track-icon">🎵</div>
|
||
<div class="track-info">
|
||
<div class="track-title">${escapeHtml(result.title || 'Unknown Title')}</div>
|
||
<div class="track-artist">by ${escapeHtml(result.artist || 'Unknown Artist')}</div>
|
||
<div class="track-details">
|
||
${sizeText} • ${escapeHtml(result.quality || 'Unknown')} ${bitrateText}
|
||
</div>
|
||
<div class="track-uploader">Shared by ${escapeHtml(result.username || 'Unknown')}</div>
|
||
</div>
|
||
<div class="track-actions">
|
||
<button onclick="streamTrack(${index})" class="track-stream-btn" title="Stream Track">Stream ▶</button>
|
||
<button onclick="downloadTrack(${index})" class="track-download-btn" title="Download">Download ⬇</button>
|
||
<button onclick="matchedDownloadTrack(${index})" class="track-matched-btn" title="Matched Download">Matched Download🎯</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
|
||
resultsArea.innerHTML = html;
|
||
// Store results globally for download functions
|
||
window.currentSearchResults = results;
|
||
}
|
||
|
||
async function downloadTrack(index) {
|
||
const results = window.currentSearchResults;
|
||
if (!results || !results[index]) return;
|
||
|
||
const track = results[index];
|
||
|
||
try {
|
||
const response = await fetch('/api/download', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(track)
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showToast(`Download started: ${track.title}`, 'success');
|
||
} else {
|
||
showToast(`Download failed: ${data.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Download error:', error);
|
||
showToast('Failed to start download', 'error');
|
||
}
|
||
}
|
||
|
||
async function downloadAlbum(index) {
|
||
const results = window.currentSearchResults;
|
||
if (!results || !results[index]) return;
|
||
|
||
const album = results[index];
|
||
|
||
try {
|
||
const response = await fetch('/api/download', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(album)
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showToast(data.message, 'success');
|
||
} else {
|
||
showToast(`Album download failed: ${data.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Album download error:', error);
|
||
showToast('Failed to start album download', 'error');
|
||
}
|
||
}
|
||
|
||
// Matched download functions
|
||
function matchedDownloadTrack(index) {
|
||
const results = window.currentSearchResults;
|
||
if (!results || !results[index]) return;
|
||
|
||
const track = results[index];
|
||
console.log('🎯 Starting matched download for single track:', track);
|
||
|
||
// Open matching modal for single track
|
||
openMatchingModal(track, false, null);
|
||
}
|
||
|
||
function matchedDownloadAlbum(index) {
|
||
const results = window.currentSearchResults;
|
||
if (!results || !results[index]) return;
|
||
|
||
const album = results[index];
|
||
console.log('🎯 Starting matched download for album:', album);
|
||
|
||
// Open matching modal for album download
|
||
openMatchingModal(album, true, album);
|
||
}
|
||
|
||
function matchedDownloadAlbumTrack(albumIndex, trackIndex) {
|
||
const results = window.currentSearchResults;
|
||
if (!results || !results[albumIndex]) return;
|
||
|
||
const album = results[albumIndex];
|
||
if (!album.tracks || !album.tracks[trackIndex]) return;
|
||
|
||
const track = album.tracks[trackIndex];
|
||
|
||
// Ensure track has necessary properties from parent album
|
||
track.username = album.username;
|
||
track.artist = track.artist || album.artist;
|
||
track.album = album.album_title || album.title;
|
||
|
||
console.log('🎯 Starting matched download for album track:', track);
|
||
|
||
// Open matching modal for single track (from album context)
|
||
openMatchingModal(track, false, null);
|
||
}
|
||
|
||
function toggleAlbumExpansion(albumIndex) {
|
||
const albumCard = document.querySelector(`[data-album-index="${albumIndex}"]`);
|
||
if (!albumCard) return;
|
||
|
||
const trackList = albumCard.querySelector('.album-track-list');
|
||
const indicator = albumCard.querySelector('.album-expand-indicator');
|
||
|
||
if (trackList.style.display === 'none' || !trackList.style.display) {
|
||
// Expand
|
||
trackList.style.display = 'block';
|
||
indicator.textContent = '▼';
|
||
albumCard.classList.add('expanded');
|
||
} else {
|
||
// Collapse
|
||
trackList.style.display = 'none';
|
||
indicator.textContent = '▶';
|
||
albumCard.classList.remove('expanded');
|
||
}
|
||
}
|
||
|
||
async function downloadAlbumTrack(albumIndex, trackIndex) {
|
||
const results = window.currentSearchResults;
|
||
if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) return;
|
||
|
||
const track = results[albumIndex].tracks[trackIndex];
|
||
|
||
try {
|
||
const response = await fetch('/api/download', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
...track,
|
||
result_type: 'track'
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showToast(`Download started: ${track.title}`, 'success');
|
||
} else {
|
||
showToast(`Track download failed: ${data.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Track download error:', error);
|
||
showToast('Failed to start track download', 'error');
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// STREAMING WRAPPER FUNCTIONS
|
||
// ===============================
|
||
|
||
async function streamTrack(index) {
|
||
// Stream a single track from search results
|
||
try {
|
||
console.log(`🎵 streamTrack called with index: ${index}`);
|
||
console.log(`🎵 window.currentSearchResults:`, window.currentSearchResults);
|
||
|
||
if (!window.currentSearchResults || !window.currentSearchResults[index]) {
|
||
console.error(`❌ No search results or invalid index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`);
|
||
showToast('Track not found', 'error');
|
||
return;
|
||
}
|
||
|
||
const result = window.currentSearchResults[index];
|
||
console.log(`🎵 Streaming track:`, result);
|
||
|
||
// Check for unsupported formats before streaming
|
||
if (result.filename && !isAudioFormatSupported(result.filename)) {
|
||
const format = getFileExtension(result.filename);
|
||
showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error');
|
||
return;
|
||
}
|
||
|
||
await startStream(result);
|
||
|
||
} catch (error) {
|
||
console.error('Track streaming error:', error);
|
||
showToast('Failed to start track stream', 'error');
|
||
}
|
||
}
|
||
|
||
|
||
async function streamAlbumTrack(albumIndex, trackIndex) {
|
||
// Stream a specific track from an album
|
||
try {
|
||
console.log(`🎵 streamAlbumTrack called with albumIndex: ${albumIndex}, trackIndex: ${trackIndex}`);
|
||
console.log(`🎵 window.currentSearchResults:`, window.currentSearchResults);
|
||
|
||
if (!window.currentSearchResults || !window.currentSearchResults[albumIndex]) {
|
||
console.error(`❌ No search results or invalid album index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`);
|
||
showToast('Album not found', 'error');
|
||
return;
|
||
}
|
||
|
||
const album = window.currentSearchResults[albumIndex];
|
||
console.log(`🎵 Album data:`, album);
|
||
|
||
if (!album.tracks || !album.tracks[trackIndex]) {
|
||
console.error(`❌ No tracks in album or invalid track index. Tracks length: ${album.tracks ? album.tracks.length : 'undefined'}`);
|
||
showToast('Track not found in album', 'error');
|
||
return;
|
||
}
|
||
|
||
const track = album.tracks[trackIndex];
|
||
console.log(`🎵 Streaming album track:`, track);
|
||
|
||
// Ensure album tracks have required fields
|
||
const trackData = {
|
||
...track,
|
||
username: track.username || album.username,
|
||
filename: track.filename || track.path,
|
||
artist: track.artist || album.artist,
|
||
album: track.album || album.title || album.album
|
||
};
|
||
|
||
console.log(`🎵 Enhanced track data:`, trackData);
|
||
|
||
// Check for unsupported formats before streaming
|
||
if (trackData.filename && !isAudioFormatSupported(trackData.filename)) {
|
||
const format = getFileExtension(trackData.filename);
|
||
showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error');
|
||
return;
|
||
}
|
||
|
||
await startStream(trackData);
|
||
|
||
} catch (error) {
|
||
console.error('Album track streaming error:', error);
|
||
showToast('Failed to start track stream', 'error');
|
||
}
|
||
}
|
||
|
||
async function loadArtistsData() {
|
||
try {
|
||
const response = await fetch(API.artists);
|
||
const data = await response.json();
|
||
|
||
const artistsGrid = document.getElementById('artists-grid');
|
||
if (data.artists && data.artists.length) {
|
||
artistsGrid.innerHTML = data.artists.map(artist => `
|
||
<div class="artist-card">
|
||
<div class="artist-image">
|
||
${artist.image ?
|
||
`<img src="${artist.image}" alt="${escapeHtml(artist.name)}" />` :
|
||
'<div class="artist-placeholder">🎵</div>'
|
||
}
|
||
</div>
|
||
<div class="artist-info">
|
||
<div class="artist-name">${escapeHtml(artist.name)}</div>
|
||
<div class="artist-albums">${artist.album_count || 0} albums</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
artistsGrid.innerHTML = '<div class="no-artists">No artists found</div>';
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading artists data:', error);
|
||
document.getElementById('artists-grid').innerHTML = '<div class="error">Error loading artists</div>';
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// UTILITY FUNCTIONS
|
||
// ===============================
|
||
|
||
function showLoadingOverlay(message = 'Loading...') {
|
||
const overlay = document.getElementById('loading-overlay');
|
||
const messageElement = overlay.querySelector('.loading-message');
|
||
messageElement.textContent = message;
|
||
overlay.classList.remove('hidden');
|
||
}
|
||
|
||
function hideLoadingOverlay() {
|
||
document.getElementById('loading-overlay').classList.add('hidden');
|
||
}
|
||
|
||
// Toast deduplication cache
|
||
let recentToasts = new Map();
|
||
|
||
function showToast(message, type = 'success') {
|
||
const container = document.getElementById('toast-container');
|
||
|
||
// Create a unique key for this toast
|
||
const toastKey = `${type}:${message}`;
|
||
const now = Date.now();
|
||
|
||
// Check if we've shown this exact toast recently (within 5 seconds)
|
||
if (recentToasts.has(toastKey)) {
|
||
const lastShown = recentToasts.get(toastKey);
|
||
if (now - lastShown < 5000) {
|
||
console.log(`🚫 Suppressing duplicate toast: "${message}"`);
|
||
return; // Don't show duplicate
|
||
}
|
||
}
|
||
|
||
// Record this toast
|
||
recentToasts.set(toastKey, now);
|
||
|
||
// Clean up old entries (older than 10 seconds)
|
||
for (const [key, timestamp] of recentToasts.entries()) {
|
||
if (now - timestamp > 10000) {
|
||
recentToasts.delete(key);
|
||
}
|
||
}
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
toast.textContent = message;
|
||
|
||
container.appendChild(toast);
|
||
|
||
// Auto-remove after 3 seconds
|
||
setTimeout(() => {
|
||
if (container.contains(toast)) {
|
||
container.removeChild(toast);
|
||
}
|
||
}, 3000);
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function formatArtists(artists) {
|
||
if (!artists || !Array.isArray(artists)) {
|
||
return 'Unknown Artist';
|
||
}
|
||
|
||
// Handle both string arrays and object arrays with 'name' property
|
||
const artistNames = artists.map(artist => {
|
||
if (typeof artist === 'string') {
|
||
return artist;
|
||
} else if (artist && typeof artist === 'object' && artist.name) {
|
||
return artist.name;
|
||
} else {
|
||
return 'Unknown Artist';
|
||
}
|
||
});
|
||
|
||
return artistNames.join(', ') || 'Unknown Artist';
|
||
}
|
||
|
||
async function showVersionInfo() {
|
||
try {
|
||
console.log('Fetching version info...');
|
||
|
||
// Fetch version data from API
|
||
const response = await fetch('/api/version-info');
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch version info');
|
||
}
|
||
|
||
const versionData = await response.json();
|
||
console.log('Version data received:', versionData);
|
||
|
||
// Populate modal content
|
||
populateVersionModal(versionData);
|
||
|
||
// Show modal
|
||
const modalOverlay = document.getElementById('version-modal-overlay');
|
||
modalOverlay.classList.remove('hidden');
|
||
|
||
console.log('Version modal opened');
|
||
|
||
} catch (error) {
|
||
console.error('Error showing version info:', error);
|
||
showToast('Failed to load version information', 'error');
|
||
}
|
||
}
|
||
|
||
function closeVersionModal() {
|
||
const modalOverlay = document.getElementById('version-modal-overlay');
|
||
modalOverlay.classList.add('hidden');
|
||
console.log('Version modal closed');
|
||
}
|
||
|
||
function populateVersionModal(versionData) {
|
||
const container = document.getElementById('version-content-container');
|
||
if (!container) {
|
||
console.error('Version content container not found');
|
||
return;
|
||
}
|
||
|
||
// Update header with dynamic data
|
||
const titleElement = document.querySelector('.version-modal-title');
|
||
const subtitleElement = document.querySelector('.version-modal-subtitle');
|
||
|
||
if (titleElement) titleElement.textContent = versionData.title;
|
||
if (subtitleElement) subtitleElement.textContent = versionData.subtitle;
|
||
|
||
// Clear existing content
|
||
container.innerHTML = '';
|
||
|
||
// Create sections
|
||
versionData.sections.forEach(section => {
|
||
const sectionDiv = document.createElement('div');
|
||
sectionDiv.className = 'version-feature-section';
|
||
|
||
// Section title
|
||
const titleDiv = document.createElement('div');
|
||
titleDiv.className = 'version-section-title';
|
||
titleDiv.textContent = section.title;
|
||
sectionDiv.appendChild(titleDiv);
|
||
|
||
// Section description
|
||
const descDiv = document.createElement('div');
|
||
descDiv.className = 'version-section-description';
|
||
descDiv.textContent = section.description;
|
||
sectionDiv.appendChild(descDiv);
|
||
|
||
// Features list
|
||
const featuresList = document.createElement('ul');
|
||
featuresList.className = 'version-feature-list';
|
||
|
||
section.features.forEach(feature => {
|
||
const featureItem = document.createElement('li');
|
||
featureItem.className = 'version-feature-item';
|
||
featureItem.textContent = feature;
|
||
featuresList.appendChild(featureItem);
|
||
});
|
||
|
||
sectionDiv.appendChild(featuresList);
|
||
|
||
// Usage note (if present)
|
||
if (section.usage_note) {
|
||
const usageDiv = document.createElement('div');
|
||
usageDiv.className = 'version-usage-note';
|
||
usageDiv.textContent = `💡 ${section.usage_note}`;
|
||
sectionDiv.appendChild(usageDiv);
|
||
}
|
||
|
||
container.appendChild(sectionDiv);
|
||
});
|
||
|
||
console.log('Version modal content populated');
|
||
}
|
||
|
||
// ===============================
|
||
// ADDITIONAL STYLES FOR SEARCH RESULTS
|
||
// ===============================
|
||
|
||
// Add dynamic styles for search results (since they're created dynamically)
|
||
const additionalStyles = `
|
||
<style>
|
||
.search-result-item {
|
||
background: rgba(255, 255, 255, 0.02);
|
||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.search-result-item:hover {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-color: rgba(29, 185, 84, 0.2);
|
||
}
|
||
|
||
.result-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.result-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.result-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
margin-bottom: 4px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.result-artist {
|
||
font-size: 12px;
|
||
color: #b3b3b3;
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.result-album {
|
||
font-size: 11px;
|
||
color: #888888;
|
||
}
|
||
|
||
.result-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.stream-button, .download-button {
|
||
padding: 6px 12px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.stream-button {
|
||
background: rgba(29, 185, 84, 0.1);
|
||
color: #1ed760;
|
||
border: 1px solid rgba(29, 185, 84, 0.3);
|
||
}
|
||
|
||
.stream-button:hover {
|
||
background: rgba(29, 185, 84, 0.2);
|
||
border-color: rgba(29, 185, 84, 0.5);
|
||
}
|
||
|
||
.download-button {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
color: rgba(255, 255, 255, 0.8);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
}
|
||
|
||
.download-button:hover {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
color: #ffffff;
|
||
}
|
||
|
||
.result-details {
|
||
display: flex;
|
||
gap: 16px;
|
||
font-size: 11px;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
}
|
||
|
||
.result-quality {
|
||
color: #1ed760;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.no-results, .no-artists, .error {
|
||
text-align: center;
|
||
color: rgba(255, 255, 255, 0.5);
|
||
padding: 40px 20px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.artist-card {
|
||
background: rgba(255, 255, 255, 0.02);
|
||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.artist-card:hover {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-color: rgba(29, 185, 84, 0.2);
|
||
}
|
||
|
||
.artist-image {
|
||
width: 120px;
|
||
height: 120px;
|
||
margin: 0 auto 12px auto;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
.artist-image img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.artist-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 32px;
|
||
color: rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
.artist-name {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
margin-bottom: 4px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.artist-albums {
|
||
font-size: 12px;
|
||
color: #b3b3b3;
|
||
}
|
||
</style>
|
||
`;
|
||
|
||
// Inject additional styles
|
||
document.head.insertAdjacentHTML('beforeend', additionalStyles);
|
||
|
||
// Make functions available globally for onclick handlers
|
||
window.openMatchingModal = openMatchingModal;
|
||
window.closeMatchingModal = closeMatchingModal;
|
||
window.selectArtist = selectArtist;
|
||
window.selectAlbum = selectAlbum;
|
||
window.navigateToPage = navigateToPage;
|
||
window.openKofi = openKofi;
|
||
window.copyAddress = copyAddress;
|
||
window.retryLastSearch = retryLastSearch;
|
||
window.showVersionInfo = showVersionInfo;
|
||
window.closeVersionModal = closeVersionModal;
|
||
window.testConnection = testConnection;
|
||
window.autoDetectPlex = autoDetectPlex;
|
||
window.autoDetectJellyfin = autoDetectJellyfin;
|
||
window.autoDetectSlskd = autoDetectSlskd;
|
||
window.toggleServer = toggleServer;
|
||
window.authenticateTidal = authenticateTidal;
|
||
window.browsePath = browsePath;
|
||
window.selectResult = selectResult;
|
||
window.startStream = startStream;
|
||
window.streamTrack = streamTrack;
|
||
window.streamAlbumTrack = streamAlbumTrack;
|
||
window.startDownload = startDownload;
|
||
window.downloadTrack = downloadTrack;
|
||
window.downloadAlbum = downloadAlbum;
|
||
window.toggleAlbumExpansion = toggleAlbumExpansion;
|
||
window.downloadAlbumTrack = downloadAlbumTrack;
|
||
window.switchDownloadTab = switchDownloadTab;
|
||
window.cancelDownloadItem = cancelDownloadItem;
|
||
window.clearFinishedDownloads = clearFinishedDownloads;
|
||
|
||
window.matchedDownloadTrack = matchedDownloadTrack;
|
||
window.matchedDownloadAlbum = matchedDownloadAlbum;
|
||
window.matchedDownloadAlbumTrack = matchedDownloadAlbumTrack;
|
||
|
||
// Download Missing Tracks Modal functions
|
||
window.openDownloadMissingModal = openDownloadMissingModal;
|
||
window.closeDownloadMissingModal = closeDownloadMissingModal;
|
||
window.startMissingTracksProcess = startMissingTracksProcess;
|
||
window.cancelAllOperations = cancelAllOperations;
|
||
window.cancelTrackDownload = cancelTrackDownload; // Legacy system
|
||
window.cancelTrackDownloadV2 = cancelTrackDownloadV2; // NEW V2 system
|
||
window.handleViewProgressClick = handleViewProgressClick;
|
||
|
||
// Wishlist Modal functions
|
||
window.openDownloadMissingWishlistModal = openDownloadMissingWishlistModal;
|
||
window.startWishlistMissingTracksProcess = startWishlistMissingTracksProcess;
|
||
window.handleWishlistButtonClick = handleWishlistButtonClick;
|
||
|
||
// Helper functions
|
||
window.escapeHtml = escapeHtml;
|
||
window.formatArtists = formatArtists;
|
||
|
||
// Artist Download Management functions
|
||
window.closeArtistDownloadModal = closeArtistDownloadModal;
|
||
window.openArtistDownloadProcess = openArtistDownloadProcess;
|
||
window.bulkCompleteArtistDownloads = bulkCompleteArtistDownloads;
|
||
window.refreshAllArtistDownloadStatuses = refreshAllArtistDownloadStatuses;
|
||
|
||
|
||
// APPEND THIS JAVASCRIPT SNIPPET (B)
|
||
|
||
function initializeFilters() {
|
||
const toggleBtn = document.getElementById('filter-toggle-btn');
|
||
const container = document.getElementById('filters-container');
|
||
const content = document.getElementById('filter-content');
|
||
|
||
if (toggleBtn && container && content) {
|
||
// Using .onclick ensures we only ever have one click handler
|
||
toggleBtn.onclick = () => {
|
||
const isExpanded = container.classList.contains('expanded');
|
||
|
||
if (isExpanded) {
|
||
// Collapse the container
|
||
container.classList.remove('expanded');
|
||
toggleBtn.textContent = '⏷ Filters';
|
||
} else {
|
||
// Expand the container
|
||
content.classList.remove('hidden'); // Make sure content is visible for animation
|
||
container.classList.add('expanded');
|
||
toggleBtn.textContent = '⏶ Filters';
|
||
}
|
||
};
|
||
}
|
||
|
||
// This part is correct and doesn't need to change
|
||
document.querySelectorAll('.filter-btn').forEach(button => {
|
||
button.addEventListener('click', handleFilterClick);
|
||
});
|
||
}
|
||
|
||
function handleFilterClick(event) {
|
||
const button = event.target;
|
||
const filterType = button.dataset.filterType;
|
||
const value = button.dataset.value;
|
||
|
||
if (filterType === 'type') currentFilterType = value;
|
||
if (filterType === 'format') currentFilterFormat = value;
|
||
if (filterType === 'sort') currentSortBy = value;
|
||
|
||
if (button.id === 'sort-order-btn') {
|
||
isSortReversed = !isSortReversed;
|
||
button.textContent = isSortReversed ? '↑' : '↓';
|
||
}
|
||
|
||
document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => {
|
||
btn.classList.remove('active');
|
||
});
|
||
if (filterType) { // Don't try to activate the sort order button
|
||
button.classList.add('active');
|
||
}
|
||
|
||
applyFiltersAndSort();
|
||
}
|
||
|
||
function resetFilters() {
|
||
currentFilterType = 'all';
|
||
currentFilterFormat = 'all';
|
||
currentSortBy = 'quality_score';
|
||
isSortReversed = false;
|
||
|
||
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
|
||
document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active');
|
||
document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active');
|
||
document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active');
|
||
document.getElementById('sort-order-btn').textContent = '↓';
|
||
}
|
||
|
||
function applyFiltersAndSort() {
|
||
let processedResults = [...allSearchResults];
|
||
const query = document.getElementById('downloads-search-input').value.trim().toLowerCase();
|
||
|
||
// 1. Filter by Type
|
||
if (currentFilterType !== 'all') {
|
||
processedResults = processedResults.filter(r => r.result_type === currentFilterType);
|
||
}
|
||
|
||
// 2. Filter by Format
|
||
if (currentFilterFormat !== 'all') {
|
||
processedResults = processedResults.filter(r => {
|
||
const quality = (r.dominant_quality || r.quality || '').toLowerCase();
|
||
return quality === currentFilterFormat;
|
||
});
|
||
}
|
||
|
||
// 3. Sort Results
|
||
processedResults.sort((a, b) => {
|
||
let valA, valB;
|
||
|
||
// Special handling for relevance sort
|
||
if (currentSortBy === 'relevance') {
|
||
valA = calculateRelevanceScore(a, query);
|
||
valB = calculateRelevanceScore(b, query);
|
||
return valB - valA; // Higher score is better
|
||
}
|
||
|
||
// Special handling for availability
|
||
if (currentSortBy === 'availability') {
|
||
valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1;
|
||
valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1;
|
||
return valB - valA;
|
||
}
|
||
|
||
valA = a[currentSortBy] || 0;
|
||
valB = b[currentSortBy] || 0;
|
||
|
||
if (typeof valA === 'string') {
|
||
// For name/title sort, use the correct property
|
||
const titleA = (a.album_title || a.title || '').toLowerCase();
|
||
const titleB = (b.album_title || b.title || '').toLowerCase();
|
||
return titleA.localeCompare(titleB);
|
||
}
|
||
|
||
// Default numeric sort (descending)
|
||
return valB - valA;
|
||
});
|
||
|
||
// Handle sort direction toggle
|
||
const sortDefaults = {
|
||
relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc',
|
||
upload_speed: 'desc', duration: 'desc', availability: 'desc',
|
||
title: 'asc', username: 'asc'
|
||
};
|
||
|
||
const defaultOrder = sortDefaults[currentSortBy] || 'desc';
|
||
if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) {
|
||
processedResults.reverse();
|
||
}
|
||
|
||
displayDownloadsResults(processedResults);
|
||
}
|
||
|
||
function calculateRelevanceScore(result, query) {
|
||
let score = 0.0;
|
||
const queryTerms = query.split(' ').filter(t => t.length > 1);
|
||
|
||
// 1. Search Term Matching (40%)
|
||
let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase();
|
||
let termMatches = 0;
|
||
for (const term of queryTerms) {
|
||
if (searchableText.includes(term)) {
|
||
termMatches++;
|
||
}
|
||
}
|
||
score += (termMatches / queryTerms.length) * 0.40;
|
||
|
||
// 2. Quality Score (25%)
|
||
score += (result.quality_score || 0) * 0.25;
|
||
|
||
// 3. User Reliability (Availability & Speed) (20%)
|
||
const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5;
|
||
score += reliability * 0.20;
|
||
|
||
// 4. File Completeness (Bitrate & Duration) (15%)
|
||
const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0);
|
||
score += completeness * 0.15;
|
||
|
||
return score;
|
||
}
|
||
// APPEND THIS JAVASCRIPT SNIPPET (B)
|
||
|
||
function initializeFilters() {
|
||
const toggleBtn = document.getElementById('filter-toggle-btn');
|
||
const container = document.getElementById('filters-container');
|
||
const content = document.getElementById('filter-content');
|
||
|
||
if (toggleBtn && container && content) {
|
||
// Using .onclick ensures we only ever have one click handler
|
||
toggleBtn.onclick = () => {
|
||
const isExpanded = container.classList.contains('expanded');
|
||
|
||
if (isExpanded) {
|
||
// Collapse the container
|
||
container.classList.remove('expanded');
|
||
toggleBtn.textContent = '⏷ Filters';
|
||
} else {
|
||
// Expand the container
|
||
content.classList.remove('hidden'); // Make sure content is visible for animation
|
||
container.classList.add('expanded');
|
||
toggleBtn.textContent = '⏶ Filters';
|
||
}
|
||
};
|
||
}
|
||
|
||
// This part is correct and doesn't need to change
|
||
document.querySelectorAll('.filter-btn').forEach(button => {
|
||
button.addEventListener('click', handleFilterClick);
|
||
});
|
||
}
|
||
|
||
function handleFilterClick(event) {
|
||
const button = event.target;
|
||
const filterType = button.dataset.filterType;
|
||
const value = button.dataset.value;
|
||
|
||
if (filterType === 'type') currentFilterType = value;
|
||
if (filterType === 'format') currentFilterFormat = value;
|
||
if (filterType === 'sort') currentSortBy = value;
|
||
|
||
if (button.id === 'sort-order-btn') {
|
||
isSortReversed = !isSortReversed;
|
||
button.textContent = isSortReversed ? '↑' : '↓';
|
||
}
|
||
|
||
document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => {
|
||
btn.classList.remove('active');
|
||
});
|
||
if (filterType) { // Don't try to activate the sort order button
|
||
button.classList.add('active');
|
||
}
|
||
|
||
applyFiltersAndSort();
|
||
}
|
||
|
||
function resetFilters() {
|
||
currentFilterType = 'all';
|
||
currentFilterFormat = 'all';
|
||
currentSortBy = 'quality_score';
|
||
isSortReversed = false;
|
||
|
||
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
|
||
document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active');
|
||
document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active');
|
||
document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active');
|
||
document.getElementById('sort-order-btn').textContent = '↓';
|
||
}
|
||
|
||
function applyFiltersAndSort() {
|
||
let processedResults = [...allSearchResults];
|
||
const query = document.getElementById('downloads-search-input').value.trim().toLowerCase();
|
||
|
||
// 1. Filter by Type
|
||
if (currentFilterType !== 'all') {
|
||
processedResults = processedResults.filter(r => r.result_type === currentFilterType);
|
||
}
|
||
|
||
// 2. Filter by Format
|
||
if (currentFilterFormat !== 'all') {
|
||
processedResults = processedResults.filter(r => {
|
||
const quality = (r.dominant_quality || r.quality || '').toLowerCase();
|
||
return quality === currentFilterFormat;
|
||
});
|
||
}
|
||
|
||
// 3. Sort Results
|
||
processedResults.sort((a, b) => {
|
||
let valA, valB;
|
||
|
||
// Special handling for relevance sort
|
||
if (currentSortBy === 'relevance') {
|
||
valA = calculateRelevanceScore(a, query);
|
||
valB = calculateRelevanceScore(b, query);
|
||
return valB - valA; // Higher score is better
|
||
}
|
||
|
||
// Special handling for availability
|
||
if (currentSortBy === 'availability') {
|
||
valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1;
|
||
valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1;
|
||
return valB - valA;
|
||
}
|
||
|
||
valA = a[currentSortBy] || 0;
|
||
valB = b[currentSortBy] || 0;
|
||
|
||
if (typeof valA === 'string') {
|
||
// For name/title sort, use the correct property
|
||
const titleA = (a.album_title || a.title || '').toLowerCase();
|
||
const titleB = (b.album_title || b.title || '').toLowerCase();
|
||
return titleA.localeCompare(titleB);
|
||
}
|
||
|
||
// Default numeric sort (descending)
|
||
return valB - valA;
|
||
});
|
||
|
||
// Handle sort direction toggle
|
||
const sortDefaults = {
|
||
relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc',
|
||
upload_speed: 'desc', duration: 'desc', availability: 'desc',
|
||
title: 'asc', username: 'asc'
|
||
};
|
||
|
||
const defaultOrder = sortDefaults[currentSortBy] || 'desc';
|
||
if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) {
|
||
processedResults.reverse();
|
||
}
|
||
|
||
displayDownloadsResults(processedResults);
|
||
}
|
||
|
||
function calculateRelevanceScore(result, query) {
|
||
let score = 0.0;
|
||
const queryTerms = query.split(' ').filter(t => t.length > 1);
|
||
|
||
// 1. Search Term Matching (40%)
|
||
let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase();
|
||
let termMatches = 0;
|
||
for (const term of queryTerms) {
|
||
if (searchableText.includes(term)) {
|
||
termMatches++;
|
||
}
|
||
}
|
||
score += (termMatches / queryTerms.length) * 0.40;
|
||
|
||
// 2. Quality Score (25%)
|
||
score += (result.quality_score || 0) * 0.25;
|
||
|
||
// 3. User Reliability (Availability & Speed) (20%)
|
||
const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5;
|
||
score += reliability * 0.20;
|
||
|
||
// 4. File Completeness (Bitrate & Duration) (15%)
|
||
const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0);
|
||
score += completeness * 0.15;
|
||
|
||
return score;
|
||
}
|
||
|
||
// Add to global scope for onclick
|
||
window.handleFilterClick = handleFilterClick;
|
||
|
||
// ===============================
|
||
// MATCHED DOWNLOADS MODAL
|
||
// ===============================
|
||
|
||
// Global state for matching modal
|
||
let currentMatchingData = {
|
||
searchResult: null,
|
||
isAlbumDownload: false,
|
||
albumResult: null,
|
||
selectedArtist: null,
|
||
selectedAlbum: null,
|
||
currentStage: 'artist' // 'artist' or 'album'
|
||
};
|
||
|
||
let searchTimers = {
|
||
artist: null,
|
||
album: null
|
||
};
|
||
|
||
function openMatchingModal(searchResult, isAlbumDownload = false, albumResult = null) {
|
||
console.log('🎯 Opening matching modal for:', searchResult);
|
||
|
||
// Store the current matching data
|
||
currentMatchingData = {
|
||
searchResult: searchResult,
|
||
isAlbumDownload: isAlbumDownload,
|
||
albumResult: albumResult,
|
||
selectedArtist: null,
|
||
selectedAlbum: null,
|
||
currentStage: 'artist'
|
||
};
|
||
|
||
// Show modal
|
||
const overlay = document.getElementById('matching-modal-overlay');
|
||
overlay.classList.remove('hidden');
|
||
|
||
// Reset modal state
|
||
resetModalState();
|
||
|
||
// Set appropriate title and stage
|
||
const modalTitle = document.getElementById('matching-modal-title');
|
||
const artistStageTitle = document.getElementById('artist-stage-title');
|
||
|
||
if (isAlbumDownload) {
|
||
modalTitle.textContent = 'Match Album Download to Spotify';
|
||
artistStageTitle.textContent = 'Step 1: Select the correct Artist';
|
||
document.getElementById('album-selection-stage').style.display = 'block';
|
||
} else {
|
||
modalTitle.textContent = 'Match Download to Spotify';
|
||
artistStageTitle.textContent = 'Select the correct Artist for this Single';
|
||
document.getElementById('album-selection-stage').style.display = 'none';
|
||
}
|
||
|
||
// Generate initial artist suggestions
|
||
fetchArtistSuggestions();
|
||
|
||
// Setup event listeners
|
||
setupModalEventListeners();
|
||
}
|
||
|
||
function closeMatchingModal() {
|
||
const overlay = document.getElementById('matching-modal-overlay');
|
||
overlay.classList.add('hidden');
|
||
|
||
// Clear timers
|
||
Object.values(searchTimers).forEach(timer => {
|
||
if (timer) clearTimeout(timer);
|
||
});
|
||
|
||
// Reset state
|
||
currentMatchingData = {
|
||
searchResult: null,
|
||
isAlbumDownload: false,
|
||
albumResult: null,
|
||
selectedArtist: null,
|
||
selectedAlbum: null,
|
||
currentStage: 'artist'
|
||
};
|
||
}
|
||
|
||
function resetModalState() {
|
||
// Show artist stage, hide album stage
|
||
document.getElementById('artist-selection-stage').classList.remove('hidden');
|
||
document.getElementById('album-selection-stage').classList.add('hidden');
|
||
|
||
// Clear all suggestion containers
|
||
document.getElementById('artist-suggestions').innerHTML = '';
|
||
document.getElementById('artist-manual-results').innerHTML = '';
|
||
document.getElementById('album-suggestions').innerHTML = '';
|
||
document.getElementById('album-manual-results').innerHTML = '';
|
||
|
||
// Clear search inputs
|
||
document.getElementById('artist-search-input').value = '';
|
||
document.getElementById('album-search-input').value = '';
|
||
|
||
// Reset button states
|
||
document.getElementById('confirm-match-btn').disabled = true;
|
||
|
||
// Reset selections
|
||
currentMatchingData.selectedArtist = null;
|
||
currentMatchingData.selectedAlbum = null;
|
||
currentMatchingData.currentStage = 'artist';
|
||
}
|
||
|
||
function setupModalEventListeners() {
|
||
// Search input listeners
|
||
const artistInput = document.getElementById('artist-search-input');
|
||
const albumInput = document.getElementById('album-search-input');
|
||
|
||
artistInput.removeEventListener('input', handleArtistSearch);
|
||
artistInput.addEventListener('input', handleArtistSearch);
|
||
|
||
albumInput.removeEventListener('input', handleAlbumSearch);
|
||
albumInput.addEventListener('input', handleAlbumSearch);
|
||
|
||
// Button listeners
|
||
const skipBtn = document.getElementById('skip-matching-btn');
|
||
const cancelBtn = document.getElementById('cancel-match-btn');
|
||
const confirmBtn = document.getElementById('confirm-match-btn');
|
||
|
||
skipBtn.onclick = skipMatching;
|
||
cancelBtn.onclick = closeMatchingModal;
|
||
confirmBtn.onclick = confirmMatch;
|
||
}
|
||
|
||
async function fetchArtistSuggestions() {
|
||
try {
|
||
showLoadingCards('artist-suggestions', 'Finding artist...');
|
||
|
||
const response = await fetch('/api/match/suggestions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
search_result: currentMatchingData.searchResult,
|
||
context: 'artist',
|
||
is_album: currentMatchingData.isAlbumDownload,
|
||
album_result: currentMatchingData.albumResult
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.suggestions) {
|
||
renderArtistSuggestions(data.suggestions);
|
||
} else {
|
||
showNoResultsMessage('artist-suggestions', 'No artist suggestions found');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching artist suggestions:', error);
|
||
showNoResultsMessage('artist-suggestions', 'Error loading suggestions');
|
||
}
|
||
}
|
||
|
||
async function fetchAlbumSuggestions() {
|
||
if (!currentMatchingData.selectedArtist) return;
|
||
|
||
try {
|
||
showLoadingCards('album-suggestions', 'Finding album...');
|
||
|
||
const response = await fetch('/api/match/suggestions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
search_result: currentMatchingData.searchResult,
|
||
context: 'album',
|
||
selected_artist: currentMatchingData.selectedArtist
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.suggestions) {
|
||
renderAlbumSuggestions(data.suggestions);
|
||
} else {
|
||
showNoResultsMessage('album-suggestions', 'No album suggestions found');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching album suggestions:', error);
|
||
showNoResultsMessage('album-suggestions', 'Error loading suggestions');
|
||
}
|
||
}
|
||
|
||
function renderArtistSuggestions(suggestions) {
|
||
const container = document.getElementById('artist-suggestions');
|
||
container.innerHTML = '';
|
||
|
||
if (!suggestions.length) {
|
||
showNoResultsMessage('artist-suggestions', 'No artist matches found');
|
||
return;
|
||
}
|
||
|
||
suggestions.forEach(suggestion => {
|
||
const card = createArtistCard(suggestion.artist, suggestion.confidence);
|
||
container.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function renderAlbumSuggestions(suggestions) {
|
||
const container = document.getElementById('album-suggestions');
|
||
container.innerHTML = '';
|
||
|
||
if (!suggestions.length) {
|
||
showNoResultsMessage('album-suggestions', 'No album matches found');
|
||
return;
|
||
}
|
||
|
||
suggestions.forEach(suggestion => {
|
||
const card = createAlbumCard(suggestion.album, suggestion.confidence);
|
||
container.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function createArtistCard(artist, confidence) {
|
||
const card = document.createElement('div');
|
||
card.className = 'suggestion-card';
|
||
card.onclick = () => selectArtist(artist);
|
||
|
||
const imageUrl = artist.image_url || '';
|
||
const confidencePercent = Math.round(confidence * 100);
|
||
|
||
card.innerHTML = `
|
||
<div class="suggestion-card-overlay"></div>
|
||
<div class="suggestion-card-content">
|
||
<div class="suggestion-card-name" title="${escapeHtml(artist.name)}">${escapeHtml(artist.name)}</div>
|
||
<div class="suggestion-card-details">
|
||
${artist.genres && artist.genres.length ? escapeHtml(artist.genres.slice(0, 2).join(', ')) : 'Artist'}
|
||
</div>
|
||
<div class="suggestion-card-confidence">${confidencePercent}% match</div>
|
||
</div>
|
||
`;
|
||
|
||
// Set background image if available
|
||
if (imageUrl) {
|
||
card.style.backgroundImage = `url(${imageUrl})`;
|
||
card.style.backgroundSize = 'cover';
|
||
card.style.backgroundPosition = 'center';
|
||
}
|
||
|
||
return card;
|
||
}
|
||
|
||
function createAlbumCard(album, confidence) {
|
||
const card = document.createElement('div');
|
||
card.className = 'suggestion-card';
|
||
card.onclick = () => selectAlbum(album);
|
||
|
||
const imageUrl = album.image_url || '';
|
||
const confidencePercent = Math.round(confidence * 100);
|
||
const year = album.release_date ? album.release_date.split('-')[0] : '';
|
||
|
||
card.innerHTML = `
|
||
<div class="suggestion-card-overlay"></div>
|
||
<div class="suggestion-card-content">
|
||
<div class="suggestion-card-name" title="${escapeHtml(album.name)}">${escapeHtml(album.name)}</div>
|
||
<div class="suggestion-card-details">
|
||
${album.album_type ? escapeHtml(album.album_type.charAt(0).toUpperCase() + album.album_type.slice(1)) : 'Album'}${year ? ` • ${year}` : ''}
|
||
</div>
|
||
<div class="suggestion-card-confidence">${confidencePercent}% match</div>
|
||
</div>
|
||
`;
|
||
|
||
// Set background image if available
|
||
if (imageUrl) {
|
||
card.style.backgroundImage = `url(${imageUrl})`;
|
||
card.style.backgroundSize = 'cover';
|
||
card.style.backgroundPosition = 'center';
|
||
}
|
||
|
||
return card;
|
||
}
|
||
|
||
function selectArtist(artist) {
|
||
// Clear previous selections
|
||
document.querySelectorAll('#artist-suggestions .suggestion-card').forEach(card => {
|
||
card.classList.remove('selected');
|
||
});
|
||
document.querySelectorAll('#artist-manual-results .suggestion-card').forEach(card => {
|
||
card.classList.remove('selected');
|
||
});
|
||
|
||
// Mark new selection
|
||
event.currentTarget.classList.add('selected');
|
||
|
||
// Store selection
|
||
currentMatchingData.selectedArtist = artist;
|
||
|
||
console.log('🎯 Selected artist:', artist.name);
|
||
|
||
if (currentMatchingData.isAlbumDownload) {
|
||
// Transition to album selection stage
|
||
transitionToAlbumStage();
|
||
} else {
|
||
// Enable confirm button for single downloads
|
||
document.getElementById('confirm-match-btn').disabled = false;
|
||
}
|
||
}
|
||
|
||
function selectAlbum(album) {
|
||
// Clear previous selections
|
||
document.querySelectorAll('#album-suggestions .suggestion-card').forEach(card => {
|
||
card.classList.remove('selected');
|
||
});
|
||
document.querySelectorAll('#album-manual-results .suggestion-card').forEach(card => {
|
||
card.classList.remove('selected');
|
||
});
|
||
|
||
// Mark new selection
|
||
event.currentTarget.classList.add('selected');
|
||
|
||
// Store selection
|
||
currentMatchingData.selectedAlbum = album;
|
||
|
||
console.log('🎯 Selected album:', album.name);
|
||
|
||
// Enable confirm button
|
||
document.getElementById('confirm-match-btn').disabled = false;
|
||
}
|
||
|
||
function transitionToAlbumStage() {
|
||
// Hide artist stage
|
||
document.getElementById('artist-selection-stage').classList.add('hidden');
|
||
|
||
// Show album stage
|
||
const albumStage = document.getElementById('album-selection-stage');
|
||
albumStage.classList.remove('hidden');
|
||
|
||
// Update selected artist name
|
||
document.getElementById('selected-artist-name').textContent = currentMatchingData.selectedArtist.name;
|
||
|
||
// Update current stage
|
||
currentMatchingData.currentStage = 'album';
|
||
|
||
// Fetch album suggestions
|
||
fetchAlbumSuggestions();
|
||
}
|
||
|
||
function handleArtistSearch(event) {
|
||
const query = event.target.value.trim();
|
||
|
||
// Clear previous timer
|
||
if (searchTimers.artist) {
|
||
clearTimeout(searchTimers.artist);
|
||
}
|
||
|
||
if (query.length < 2) {
|
||
document.getElementById('artist-manual-results').innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
// Debounce search
|
||
searchTimers.artist = setTimeout(() => {
|
||
performArtistSearch(query);
|
||
}, 400);
|
||
}
|
||
|
||
function handleAlbumSearch(event) {
|
||
const query = event.target.value.trim();
|
||
|
||
// Clear previous timer
|
||
if (searchTimers.album) {
|
||
clearTimeout(searchTimers.album);
|
||
}
|
||
|
||
if (query.length < 2) {
|
||
document.getElementById('album-manual-results').innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
// Debounce search
|
||
searchTimers.album = setTimeout(() => {
|
||
performAlbumSearch(query);
|
||
}, 400);
|
||
}
|
||
|
||
async function performArtistSearch(query) {
|
||
try {
|
||
showLoadingCards('artist-manual-results', 'Searching artists...');
|
||
|
||
const response = await fetch('/api/match/search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
query: query,
|
||
context: 'artist'
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.results) {
|
||
renderArtistSearchResults(data.results);
|
||
} else {
|
||
showNoResultsMessage('artist-manual-results', 'No artists found');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error searching artists:', error);
|
||
showNoResultsMessage('artist-manual-results', 'Error searching artists');
|
||
}
|
||
}
|
||
|
||
async function performAlbumSearch(query) {
|
||
if (!currentMatchingData.selectedArtist) return;
|
||
|
||
try {
|
||
showLoadingCards('album-manual-results', 'Searching albums...');
|
||
|
||
const response = await fetch('/api/match/search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
query: query,
|
||
context: 'album',
|
||
artist_id: currentMatchingData.selectedArtist.id
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.results) {
|
||
renderAlbumSearchResults(data.results);
|
||
} else {
|
||
showNoResultsMessage('album-manual-results', 'No albums found');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error searching albums:', error);
|
||
showNoResultsMessage('album-manual-results', 'Error searching albums');
|
||
}
|
||
}
|
||
|
||
function renderArtistSearchResults(results) {
|
||
const container = document.getElementById('artist-manual-results');
|
||
container.innerHTML = '';
|
||
|
||
results.forEach(result => {
|
||
const card = createArtistCard(result.artist, result.confidence);
|
||
container.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function renderAlbumSearchResults(results) {
|
||
const container = document.getElementById('album-manual-results');
|
||
container.innerHTML = '';
|
||
|
||
results.forEach(result => {
|
||
const card = createAlbumCard(result.album, result.confidence);
|
||
container.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function showLoadingCards(containerId, message) {
|
||
const container = document.getElementById(containerId);
|
||
container.innerHTML = `<div class="loading-card">${message}</div>`;
|
||
}
|
||
|
||
function showNoResultsMessage(containerId, message) {
|
||
const container = document.getElementById(containerId);
|
||
container.innerHTML = `<div class="loading-card" style="color: rgba(255,255,255,0.5)">${message}</div>`;
|
||
}
|
||
|
||
function skipMatching() {
|
||
console.log('🎯 Skipping matching, proceeding with normal download');
|
||
|
||
// Close modal
|
||
closeMatchingModal();
|
||
|
||
// Start normal download
|
||
if (currentMatchingData.isAlbumDownload) {
|
||
// For albums, we need to download each track
|
||
showToast('⬇️ Starting album download (unmatched)', 'info');
|
||
// This would need to be implemented to download all album tracks
|
||
} else {
|
||
// Single track download
|
||
startDownload(window.currentSearchResults.indexOf(currentMatchingData.searchResult));
|
||
}
|
||
}
|
||
|
||
async function confirmMatch() {
|
||
if (!currentMatchingData.selectedArtist) {
|
||
showToast('⚠️ Please select an artist first', 'error');
|
||
return;
|
||
}
|
||
|
||
if (currentMatchingData.isAlbumDownload && !currentMatchingData.selectedAlbum) {
|
||
showToast('⚠️ Please select an album first', 'error');
|
||
return;
|
||
}
|
||
|
||
const confirmBtn = document.getElementById('confirm-match-btn');
|
||
const originalText = confirmBtn.textContent; // FIX: Declare outside try block
|
||
|
||
try {
|
||
console.log('🎯 Confirming match with:', {
|
||
artist: currentMatchingData.selectedArtist.name,
|
||
album: currentMatchingData.selectedAlbum?.name
|
||
});
|
||
|
||
confirmBtn.disabled = true;
|
||
confirmBtn.textContent = 'Starting...';
|
||
|
||
// --- THIS IS THE CRITICAL FIX ---
|
||
// Determine the correct data to send. For albums, we send the full albumResult
|
||
// which contains the complete list of tracks.
|
||
const downloadPayload = currentMatchingData.isAlbumDownload
|
||
? currentMatchingData.albumResult
|
||
: currentMatchingData.searchResult;
|
||
// --- END OF FIX ---
|
||
|
||
const response = await fetch('/api/download/matched', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
search_result: downloadPayload, // Send the correct payload
|
||
spotify_artist: currentMatchingData.selectedArtist,
|
||
spotify_album: currentMatchingData.selectedAlbum || null
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
showToast(`🎯 Matched download started for "${currentMatchingData.selectedArtist.name}"`, 'success');
|
||
closeMatchingModal();
|
||
} else {
|
||
throw new Error(data.error || 'Failed to start matched download');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error starting matched download:', error);
|
||
showToast(`❌ Error starting matched download: ${error.message}`, 'error');
|
||
|
||
// Re-enable confirm button on failure
|
||
confirmBtn.disabled = false;
|
||
confirmBtn.textContent = originalText;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
function matchedDownloadTrack(trackIndex) {
|
||
const results = window.currentSearchResults;
|
||
if (!results || !results[trackIndex]) {
|
||
console.error('Could not find track for matched download:', trackIndex);
|
||
showToast('Error preparing matched download.', 'error');
|
||
return;
|
||
}
|
||
const trackData = results[trackIndex];
|
||
// It's a single track, so isAlbumDownload is false and there's no album context.
|
||
openMatchingModal(trackData, false, null);
|
||
}
|
||
|
||
function matchedDownloadAlbum(albumIndex) {
|
||
const results = window.currentSearchResults;
|
||
if (!results || !results[albumIndex]) {
|
||
console.error('Could not find album for matched download:', albumIndex);
|
||
showToast('Error preparing matched download.', 'error');
|
||
return;
|
||
}
|
||
const albumData = results[albumIndex];
|
||
// The first track is used as a reference for the initial artist search.
|
||
const firstTrack = albumData.tracks ? albumData.tracks[0] : albumData;
|
||
openMatchingModal(firstTrack, true, albumData);
|
||
}
|
||
|
||
function matchedDownloadAlbumTrack(albumIndex, trackIndex) {
|
||
const results = window.currentSearchResults;
|
||
if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) {
|
||
console.error('Could not find album track for matched download:', albumIndex, trackIndex);
|
||
showToast('Error preparing matched download.', 'error');
|
||
return;
|
||
}
|
||
const albumData = results[albumIndex];
|
||
const trackData = albumData.tracks[trackIndex];
|
||
|
||
// This is the definitive fix.
|
||
// The second argument MUST be 'false' to treat this as a single track download,
|
||
// which prevents the modal from asking for an album selection.
|
||
openMatchingModal(trackData, false, albumData);
|
||
}
|
||
|
||
// ===========================================
|
||
// == DASHBOARD DATABASE UPDATER FUNCTIONALITY ==
|
||
// ===========================================
|
||
|
||
// --- State and Polling Management ---
|
||
|
||
function stopDbStatsPolling() {
|
||
if (dbStatsInterval) {
|
||
clearInterval(dbStatsInterval);
|
||
dbStatsInterval = null;
|
||
}
|
||
}
|
||
|
||
function stopDbUpdatePolling() {
|
||
if (dbUpdateStatusInterval) {
|
||
clearInterval(dbUpdateStatusInterval);
|
||
dbUpdateStatusInterval = null;
|
||
}
|
||
}
|
||
|
||
function stopWishlistCountPolling() {
|
||
if (wishlistCountInterval) {
|
||
clearInterval(wishlistCountInterval);
|
||
wishlistCountInterval = null;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
function resetWishlistModalToIdleState() {
|
||
// Reset wishlist modal to idle state after background processing completes
|
||
const playlistId = 'wishlist';
|
||
const process = activeDownloadProcesses[playlistId];
|
||
|
||
if (process) {
|
||
console.log('🔄 Resetting wishlist modal to idle state...');
|
||
|
||
// Reset button states
|
||
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
|
||
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
|
||
if (beginBtn) {
|
||
beginBtn.style.display = 'inline-block';
|
||
beginBtn.disabled = false;
|
||
beginBtn.textContent = 'Begin Analysis';
|
||
}
|
||
if (cancelBtn) {
|
||
cancelBtn.style.display = 'none';
|
||
}
|
||
|
||
// Reset progress displays
|
||
const analysisText = document.getElementById(`analysis-progress-text-${playlistId}`);
|
||
const analysisBar = document.getElementById(`analysis-progress-fill-${playlistId}`);
|
||
const downloadText = document.getElementById(`download-progress-text-${playlistId}`);
|
||
const downloadBar = document.getElementById(`download-progress-fill-${playlistId}`);
|
||
|
||
if (analysisText) analysisText.textContent = 'Ready to start';
|
||
if (analysisBar) analysisBar.style.width = '0%';
|
||
if (downloadText) downloadText.textContent = 'Waiting for analysis';
|
||
if (downloadBar) downloadBar.style.width = '0%';
|
||
|
||
// Reset all track rows to pending state
|
||
const trackRows = document.querySelectorAll(`#download-missing-modal-${playlistId} tr[data-track-index]`);
|
||
trackRows.forEach((row, index) => {
|
||
const matchCell = row.querySelector(`#match-${playlistId}-${index}`);
|
||
const downloadCell = row.querySelector(`#download-${playlistId}-${index}`);
|
||
const actionsCell = row.querySelector(`#actions-${playlistId}-${index}`);
|
||
|
||
if (matchCell) matchCell.textContent = '🔍 Pending';
|
||
if (downloadCell) downloadCell.textContent = '-';
|
||
if (actionsCell) actionsCell.innerHTML = '-';
|
||
});
|
||
|
||
// Reset stats
|
||
const foundElement = document.getElementById(`stat-found-${playlistId}`);
|
||
const missingElement = document.getElementById(`stat-missing-${playlistId}`);
|
||
const downloadedElement = document.getElementById(`stat-downloaded-${playlistId}`);
|
||
if (foundElement) foundElement.textContent = '-';
|
||
if (missingElement) missingElement.textContent = '-';
|
||
if (downloadedElement) downloadedElement.textContent = '0';
|
||
|
||
// Reset process status
|
||
process.status = 'idle';
|
||
process.batchId = null;
|
||
if (process.poller) {
|
||
clearInterval(process.poller);
|
||
process.poller = null;
|
||
}
|
||
|
||
console.log('✅ Wishlist modal fully reset to idle state');
|
||
} else {
|
||
console.log('⚠️ No wishlist process found to reset');
|
||
}
|
||
}
|
||
|
||
async function loadDashboardData() {
|
||
// Attach event listeners for the DB updater tool
|
||
const updateButton = document.getElementById('db-update-button');
|
||
if (updateButton) {
|
||
updateButton.addEventListener('click', handleDbUpdateButtonClick);
|
||
}
|
||
|
||
// Attach event listener for the wishlist button
|
||
const wishlistButton = document.getElementById('wishlist-button');
|
||
if (wishlistButton) {
|
||
wishlistButton.addEventListener('click', handleWishlistButtonClick);
|
||
}
|
||
|
||
// Initial load of stats
|
||
await fetchAndUpdateDbStats();
|
||
|
||
// Start periodic refresh of stats (every 30 seconds)
|
||
stopDbStatsPolling(); // Ensure no duplicates
|
||
dbStatsInterval = setInterval(fetchAndUpdateDbStats, 30000);
|
||
|
||
// Initial load of wishlist count
|
||
await updateWishlistCount();
|
||
|
||
// Start periodic refresh of wishlist count (every 30 seconds, matching GUI behavior)
|
||
stopWishlistCountPolling(); // Ensure no duplicates
|
||
wishlistCountInterval = setInterval(updateWishlistCount, 30000);
|
||
|
||
// Also check the status of any ongoing update when the page loads
|
||
await checkAndUpdateDbProgress();
|
||
|
||
// Check for any active download processes that need rehydration
|
||
await checkForActiveProcesses();
|
||
|
||
// Automatic wishlist processing now runs server-side
|
||
}
|
||
|
||
// --- Data Fetching and UI Updates ---
|
||
|
||
async function fetchAndUpdateDbStats() {
|
||
try {
|
||
const response = await fetch('/api/database/stats');
|
||
if (!response.ok) return;
|
||
|
||
const stats = await response.json();
|
||
|
||
// This function updates the stat cards in the top grid
|
||
updateDashboardStatCards(stats);
|
||
|
||
// This function updates the info within the DB Updater tool card
|
||
updateDbUpdaterCardInfo(stats);
|
||
|
||
} catch (error) {
|
||
console.warn('Could not fetch DB stats:', error);
|
||
}
|
||
}
|
||
|
||
function updateDashboardStatCards(stats) {
|
||
// You can expand this later to update the main stat cards
|
||
// For now, we focus on the updater tool itself.
|
||
}
|
||
|
||
|
||
|
||
function updateDbUpdaterCardInfo(stats) {
|
||
// Update the detailed stats within the DB Updater tool card
|
||
const lastRefreshEl = document.getElementById('db-last-refresh');
|
||
const artistsStatEl = document.getElementById('db-stat-artists');
|
||
const albumsStatEl = document.getElementById('db-stat-albums');
|
||
const tracksStatEl = document.getElementById('db-stat-tracks');
|
||
const sizeStatEl = document.getElementById('db-stat-size');
|
||
|
||
if (lastRefreshEl) {
|
||
if (stats.last_full_refresh) {
|
||
const date = new Date(stats.last_full_refresh);
|
||
lastRefreshEl.textContent = date.toLocaleString();
|
||
} else {
|
||
lastRefreshEl.textContent = 'Never';
|
||
}
|
||
}
|
||
|
||
if (artistsStatEl) artistsStatEl.textContent = stats.artists.toLocaleString() || '0';
|
||
if (albumsStatEl) albumsStatEl.textContent = stats.albums.toLocaleString() || '0';
|
||
if (tracksStatEl) tracksStatEl.textContent = stats.tracks.toLocaleString() || '0';
|
||
if (sizeStatEl) sizeStatEl.textContent = `${stats.database_size_mb.toFixed(2)} MB`;
|
||
|
||
// Update the title of the tool card to show which server is active
|
||
const toolCardTitle = document.querySelector('#db-updater-card .tool-card-title');
|
||
if (toolCardTitle && stats.server_source) {
|
||
const serverName = stats.server_source.charAt(0).toUpperCase() + stats.server_source.slice(1);
|
||
toolCardTitle.textContent = `${serverName} Database Updater`;
|
||
}
|
||
}
|
||
|
||
// --- Wishlist Count Functions ---
|
||
|
||
async function updateWishlistCount() {
|
||
try {
|
||
const response = await fetch('/api/wishlist/count');
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
const count = data.count || 0;
|
||
|
||
const wishlistButton = document.getElementById('wishlist-button');
|
||
if (wishlistButton) {
|
||
wishlistButton.textContent = `🎵 Wishlist (${count})`;
|
||
|
||
// Update button styling based on count (matching GUI behavior)
|
||
if (count === 0) {
|
||
wishlistButton.classList.remove('wishlist-active');
|
||
wishlistButton.classList.add('wishlist-inactive');
|
||
} else {
|
||
wishlistButton.classList.remove('wishlist-inactive');
|
||
wishlistButton.classList.add('wishlist-active');
|
||
}
|
||
}
|
||
|
||
// Check for auto-initiated wishlist processes that user should see immediately
|
||
await checkForAutoInitiatedWishlistProcess();
|
||
|
||
} catch (error) {
|
||
console.warn('Could not fetch wishlist count:', error);
|
||
}
|
||
}
|
||
|
||
async function checkForAutoInitiatedWishlistProcess() {
|
||
try {
|
||
const playlistId = 'wishlist';
|
||
|
||
// Only check if we're on the dashboard and no modal is currently visible
|
||
if (currentPage !== 'dashboard') {
|
||
return;
|
||
}
|
||
|
||
// Don't override if user has manually closed the modal during auto-processing
|
||
if (WishlistModalState.wasUserClosed()) {
|
||
return;
|
||
}
|
||
|
||
// Check for active wishlist processes
|
||
const response = await fetch('/api/active-processes');
|
||
if (!response.ok) return;
|
||
|
||
const data = await response.json();
|
||
const processes = data.active_processes || [];
|
||
const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId);
|
||
const clientWishlistProcess = activeDownloadProcesses[playlistId];
|
||
|
||
if (serverWishlistProcess && serverWishlistProcess.auto_initiated) {
|
||
console.log('🤖 [Auto-Processing] Detected auto-initiated wishlist process during polling');
|
||
|
||
// Only sync frontend state if needed, but don't auto-show modal
|
||
const needsSync = !clientWishlistProcess ||
|
||
clientWishlistProcess.batchId !== serverWishlistProcess.batch_id ||
|
||
!clientWishlistProcess.modalElement ||
|
||
!document.body.contains(clientWishlistProcess.modalElement);
|
||
|
||
if (needsSync) {
|
||
console.log('🔄 [Auto-Processing] Syncing frontend state for auto-processing (background mode)');
|
||
await rehydrateModal(serverWishlistProcess, false); // Background sync only
|
||
}
|
||
|
||
// Note: Modal visibility is controlled by user interaction only
|
||
// User must click wishlist button to see auto-processing progress
|
||
}
|
||
|
||
} catch (error) {
|
||
console.warn('Error checking for auto-initiated wishlist process:', error);
|
||
}
|
||
}
|
||
|
||
async function checkAndUpdateDbProgress() {
|
||
try {
|
||
const response = await fetch('/api/database/update/status');
|
||
if (!response.ok) return;
|
||
|
||
const state = await response.json();
|
||
updateDbProgressUI(state);
|
||
|
||
if (state.status === 'running') {
|
||
// If an update is running, start polling for progress
|
||
stopDbUpdatePolling();
|
||
dbUpdateStatusInterval = setInterval(checkAndUpdateDbProgress, 1000);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.warn('Could not fetch DB update status:', error);
|
||
}
|
||
}
|
||
|
||
function updateDbProgressUI(state) {
|
||
const button = document.getElementById('db-update-button');
|
||
const phaseLabel = document.getElementById('db-phase-label');
|
||
const progressLabel = document.getElementById('db-progress-label');
|
||
const progressBar = document.getElementById('db-progress-bar');
|
||
const refreshSelect = document.getElementById('db-refresh-type');
|
||
|
||
if (!button || !phaseLabel || !progressLabel || !progressBar || !refreshSelect) return;
|
||
|
||
if (state.status === 'running') {
|
||
button.textContent = 'Stop Update';
|
||
button.disabled = false;
|
||
refreshSelect.disabled = true;
|
||
|
||
phaseLabel.textContent = state.phase || 'Processing...';
|
||
progressLabel.textContent = `${state.processed} / ${state.total} artists (${state.progress.toFixed(1)}%)`;
|
||
progressBar.style.width = `${state.progress}%`;
|
||
} else { // idle, finished, or error
|
||
stopDbUpdatePolling();
|
||
button.textContent = 'Update Database';
|
||
button.disabled = false;
|
||
refreshSelect.disabled = false;
|
||
|
||
if (state.status === 'error') {
|
||
phaseLabel.textContent = `Error: ${state.error_message}`;
|
||
progressBar.style.backgroundColor = '#ff4444'; // Red for error
|
||
} else {
|
||
phaseLabel.textContent = state.phase || 'Idle';
|
||
progressBar.style.backgroundColor = '#1db954'; // Green for normal
|
||
}
|
||
|
||
if (state.status === 'finished' || state.status === 'error') {
|
||
// Final stats refresh after completion/error
|
||
setTimeout(fetchAndUpdateDbStats, 500);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===================================================================
|
||
// TIDAL PLAYLIST MANAGEMENT (YouTube-style cards with Tidal colors)
|
||
// ===================================================================
|
||
|
||
async function loadTidalPlaylists() {
|
||
const container = document.getElementById('tidal-playlist-container');
|
||
const refreshBtn = document.getElementById('tidal-refresh-btn');
|
||
|
||
container.innerHTML = `<div class="playlist-placeholder">🔄 Loading Tidal playlists...</div>`;
|
||
refreshBtn.disabled = true;
|
||
refreshBtn.textContent = '🔄 Loading...';
|
||
|
||
try {
|
||
const response = await fetch('/api/tidal/playlists');
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || 'Failed to fetch Tidal playlists');
|
||
}
|
||
|
||
tidalPlaylists = await response.json();
|
||
renderTidalPlaylists();
|
||
tidalPlaylistsLoaded = true;
|
||
|
||
console.log(`🎵 Loaded ${tidalPlaylists.length} Tidal playlists`);
|
||
|
||
// Load and apply saved discovery states from backend (like YouTube)
|
||
await loadTidalPlaylistStatesFromBackend();
|
||
|
||
} catch (error) {
|
||
container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${error.message}</div>`;
|
||
showToast(`Error loading Tidal playlists: ${error.message}`, 'error');
|
||
} finally {
|
||
refreshBtn.disabled = false;
|
||
refreshBtn.textContent = '🔄 Refresh';
|
||
}
|
||
}
|
||
|
||
function renderTidalPlaylists() {
|
||
const container = document.getElementById('tidal-playlist-container');
|
||
if (tidalPlaylists.length === 0) {
|
||
container.innerHTML = `<div class="playlist-placeholder">No Tidal playlists found.</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = tidalPlaylists.map(p => {
|
||
// Initialize state if not exists (fresh state like sync.py)
|
||
if (!tidalPlaylistStates[p.id]) {
|
||
tidalPlaylistStates[p.id] = {
|
||
phase: 'fresh',
|
||
playlist: p
|
||
};
|
||
}
|
||
|
||
return createTidalCard(p);
|
||
}).join('');
|
||
|
||
// Add click handlers to cards
|
||
tidalPlaylists.forEach(p => {
|
||
const card = document.getElementById(`tidal-card-${p.id}`);
|
||
if (card) {
|
||
card.addEventListener('click', () => handleTidalCardClick(p.id));
|
||
}
|
||
});
|
||
}
|
||
|
||
function createTidalCard(playlist) {
|
||
const state = tidalPlaylistStates[playlist.id];
|
||
const phase = state.phase;
|
||
|
||
// Get phase-specific button text (like YouTube cards)
|
||
let buttonText = getActionButtonText(phase);
|
||
let phaseText = getPhaseText(phase);
|
||
let phaseColor = getPhaseColor(phase);
|
||
|
||
return `
|
||
<div class="youtube-playlist-card tidal-playlist-card" id="tidal-card-${playlist.id}">
|
||
<div class="playlist-card-icon">🎵</div>
|
||
<div class="playlist-card-content">
|
||
<div class="playlist-card-name">${escapeHtml(playlist.name)}</div>
|
||
<div class="playlist-card-info">
|
||
<span class="playlist-card-track-count">${playlist.track_count} tracks</span>
|
||
<span class="playlist-card-phase-text" style="color: ${phaseColor};">${phaseText}</span>
|
||
</div>
|
||
</div>
|
||
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}">
|
||
<!-- Progress will be dynamically updated based on phase -->
|
||
</div>
|
||
<button class="playlist-card-action-btn">${buttonText}</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function handleTidalCardClick(playlistId) {
|
||
// Robust state validation
|
||
const state = tidalPlaylistStates[playlistId];
|
||
if (!state) {
|
||
console.error(`❌ [Card Click] No state found for Tidal playlist: ${playlistId}`);
|
||
showToast('Playlist state not found - try refreshing the page', 'error');
|
||
return;
|
||
}
|
||
|
||
// Validate required state data
|
||
if (!state.playlist) {
|
||
console.error(`❌ [Card Click] No playlist data found for Tidal playlist: ${playlistId}`);
|
||
showToast('Playlist data missing - try refreshing the page', 'error');
|
||
return;
|
||
}
|
||
|
||
// Validate phase
|
||
if (!state.phase) {
|
||
console.warn(`⚠️ [Card Click] No phase set for Tidal playlist ${playlistId} - defaulting to 'fresh'`);
|
||
state.phase = 'fresh';
|
||
}
|
||
|
||
console.log(`🎵 [Card Click] Tidal card clicked: ${playlistId}, Phase: ${state.phase}`);
|
||
|
||
if (state.phase === 'fresh') {
|
||
// No need to fetch data - we already have all tracks from initial load (like sync.py)
|
||
console.log(`🎵 Using pre-loaded Tidal playlist data for: ${state.playlist.name}`);
|
||
console.log(`🎵 Ready with ${state.playlist.tracks.length} Tidal tracks for discovery`);
|
||
|
||
// Open discovery modal - phase will be updated when discovery actually starts
|
||
openTidalDiscoveryModal(playlistId, state.playlist);
|
||
|
||
} else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') {
|
||
// Reopen existing modal with preserved discovery results (like GUI sync.py)
|
||
console.log(`🎵 [Card Click] Opening Tidal discovery modal for ${state.phase} phase`);
|
||
|
||
// Validate that we have discovery results to show
|
||
if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) {
|
||
console.warn(`⚠️ [Card Click] Discovered phase but no discovery results found - attempting to reload from backend`);
|
||
|
||
// Try to fetch from backend as fallback
|
||
try {
|
||
const stateResponse = await fetch(`/api/tidal/state/${playlistId}`);
|
||
if (stateResponse.ok) {
|
||
const fullState = await stateResponse.json();
|
||
if (fullState.discovery_results) {
|
||
// Merge backend state with current state
|
||
state.discovery_results = fullState.discovery_results;
|
||
state.spotify_matches = fullState.spotify_matches || state.spotify_matches;
|
||
state.discovery_progress = fullState.discovery_progress || state.discovery_progress;
|
||
tidalPlaylistStates[playlistId] = {...tidalPlaylistStates[playlistId], ...state};
|
||
console.log(`✅ [Card Click] Restored ${fullState.discovery_results.length} discovery results from backend`);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ [Card Click] Failed to fetch discovery results from backend: ${error}`);
|
||
}
|
||
}
|
||
|
||
openTidalDiscoveryModal(playlistId, state.playlist);
|
||
} else if (state.phase === 'downloading' || state.phase === 'download_complete') {
|
||
// Open download modal if we have the converted playlist ID
|
||
if (state.convertedSpotifyPlaylistId) {
|
||
console.log(`🔍 [Card Click] Opening download modal for Tidal playlist: ${state.playlist.name} (phase: ${state.phase})`);
|
||
// Check if modal already exists, if not create it
|
||
if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) {
|
||
const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId];
|
||
if (process.modalElement) {
|
||
console.log(`📱 [Card Click] Showing existing download modal for ${state.phase} phase`);
|
||
process.modalElement.style.display = 'flex';
|
||
} else {
|
||
console.warn(`⚠️ [Card Click] Download process exists but modal element missing - rehydrating`);
|
||
await rehydrateTidalDownloadModal(playlistId, state);
|
||
}
|
||
} else {
|
||
// Need to create the download modal - fetch the discovery results
|
||
console.log(`🔧 [Card Click] Rehydrating Tidal download modal for ${state.phase} phase`);
|
||
await rehydrateTidalDownloadModal(playlistId, state);
|
||
}
|
||
} else {
|
||
console.error('❌ [Card Click] No converted Spotify playlist ID found for Tidal download modal');
|
||
console.log('📊 [Card Click] Available state data:', Object.keys(state));
|
||
|
||
// Fallback: try to open discovery modal if we have discovery results
|
||
if (state.discovery_results && state.discovery_results.length > 0) {
|
||
console.log(`🔄 [Card Click] Fallback: Opening discovery modal with ${state.discovery_results.length} results`);
|
||
openTidalDiscoveryModal(playlistId, state.playlist);
|
||
} else {
|
||
showToast('Unable to open download modal - missing playlist data', 'error');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function rehydrateTidalDownloadModal(playlistId, state) {
|
||
try {
|
||
// Robust state validation for rehydration
|
||
if (!state || !state.playlist) {
|
||
console.error(`❌ [Rehydration] Invalid state data for Tidal playlist: ${playlistId}`);
|
||
showToast('Cannot open download modal - invalid playlist data', 'error');
|
||
return;
|
||
}
|
||
|
||
console.log(`💧 [Rehydration] Rehydrating Tidal download modal for: ${state.playlist.name}`);
|
||
|
||
// Get discovery results from backend if not already loaded
|
||
if (!state.discovery_results) {
|
||
console.log(`🔍 Fetching discovery results from backend for Tidal playlist: ${playlistId}`);
|
||
const stateResponse = await fetch(`/api/tidal/state/${playlistId}`);
|
||
if (stateResponse.ok) {
|
||
const fullState = await stateResponse.json();
|
||
state.discovery_results = fullState.discovery_results;
|
||
state.convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
|
||
state.download_process_id = fullState.download_process_id;
|
||
console.log(`✅ Loaded ${fullState.discovery_results?.length || 0} discovery results from backend`);
|
||
} else {
|
||
console.error('❌ Failed to fetch Tidal discovery results from backend');
|
||
showToast('Error loading playlist data', 'error');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Extract Spotify tracks from discovery results
|
||
const spotifyTracks = [];
|
||
for (const result of state.discovery_results) {
|
||
if (result.spotify_data) {
|
||
spotifyTracks.push(result.spotify_data);
|
||
}
|
||
}
|
||
|
||
if (spotifyTracks.length === 0) {
|
||
console.error('❌ No Spotify tracks found for download modal');
|
||
showToast('No Spotify matches found for download', 'error');
|
||
return;
|
||
}
|
||
|
||
const virtualPlaylistId = state.convertedSpotifyPlaylistId;
|
||
const playlistName = `[Tidal] ${state.playlist.name}`;
|
||
|
||
// Create the download modal
|
||
await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks);
|
||
|
||
// If we have a download process ID, set up the modal for the running state
|
||
if (state.download_process_id) {
|
||
const process = activeDownloadProcesses[virtualPlaylistId];
|
||
if (process) {
|
||
process.status = state.phase === 'download_complete' ? 'complete' : 'running';
|
||
process.batchId = state.download_process_id;
|
||
|
||
// Update UI based on phase
|
||
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
|
||
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
|
||
|
||
if (state.phase === 'downloading') {
|
||
if (beginBtn) beginBtn.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
||
|
||
// Start polling for live updates
|
||
startModalDownloadPolling(virtualPlaylistId);
|
||
console.log(`🔄 Started polling for active Tidal download: ${state.download_process_id}`);
|
||
} else if (state.phase === 'download_complete') {
|
||
if (beginBtn) beginBtn.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'none';
|
||
console.log(`✅ Showing completed Tidal download results: ${state.download_process_id}`);
|
||
|
||
// For completed downloads, fetch the final results once to populate the modal
|
||
try {
|
||
const response = await fetch(`/api/playlists/${state.download_process_id}/download_status`);
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.phase === 'complete' && data.tasks) {
|
||
console.log(`📊 [Rehydration] Loading ${data.tasks.length} completed tasks for modal display`);
|
||
// Process the completed tasks to update modal display
|
||
updateCompletedModalResults(virtualPlaylistId, data);
|
||
} else {
|
||
console.warn(`⚠️ [Rehydration] Unexpected data from download_status: phase=${data.phase}, tasks=${data.tasks?.length || 0}`);
|
||
}
|
||
} else {
|
||
console.error(`❌ [Rehydration] Failed to fetch download status: ${response.status} ${response.statusText}`);
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ [Rehydration] Error fetching final results for completed download: ${error}`);
|
||
// Show a user-friendly message but still allow modal to open
|
||
showToast('Could not load download results - modal may show incomplete data', 'warning', 3000);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(`✅ Successfully rehydrated Tidal download modal for: ${state.playlist.name}`);
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error rehydrating Tidal download modal:`, error);
|
||
showToast('Error opening download modal', 'error');
|
||
}
|
||
}
|
||
|
||
function updateCompletedModalResults(playlistId, downloadData) {
|
||
/**
|
||
* Update a completed download modal with final results
|
||
* This reuses the existing status polling logic but applies it once for completed state
|
||
*/
|
||
console.log(`📊 [Completed Results] Updating modal ${playlistId} with final download results`);
|
||
|
||
// Validate input data
|
||
if (!downloadData || !downloadData.tasks) {
|
||
console.error(`❌ [Completed Results] Invalid download data for playlist ${playlistId}:`, downloadData);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Update analysis progress to 100%
|
||
const analysisProgressFill = document.getElementById(`analysis-progress-fill-${playlistId}`);
|
||
const analysisProgressText = document.getElementById(`analysis-progress-text-${playlistId}`);
|
||
if (analysisProgressFill) analysisProgressFill.style.width = '100%';
|
||
if (analysisProgressText) analysisProgressText.textContent = 'Analysis complete!';
|
||
|
||
// Update analysis results and stats
|
||
if (downloadData.analysis_results) {
|
||
updateTrackAnalysisResults(playlistId, downloadData.analysis_results);
|
||
const foundCount = downloadData.analysis_results.filter(r => r.found).length;
|
||
const missingCount = downloadData.analysis_results.filter(r => !r.found).length;
|
||
|
||
const statFound = document.getElementById(`stat-found-${playlistId}`);
|
||
const statMissing = document.getElementById(`stat-missing-${playlistId}`);
|
||
if (statFound) statFound.textContent = foundCount;
|
||
if (statMissing) statMissing.textContent = missingCount;
|
||
}
|
||
|
||
// Process completed tasks to update individual track statuses
|
||
const missingTracks = (downloadData.analysis_results || []).filter(r => !r.found);
|
||
let completedCount = 0;
|
||
let failedOrCancelledCount = 0;
|
||
|
||
(downloadData.tasks || []).forEach(task => {
|
||
const row = document.querySelector(`#download-missing-modal-${playlistId} tr[data-track-index="${task.track_index}"]`);
|
||
if (!row) return;
|
||
|
||
row.dataset.taskId = task.task_id;
|
||
const statusEl = document.getElementById(`download-${playlistId}-${task.track_index}`);
|
||
const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`);
|
||
|
||
let statusText = '';
|
||
switch (task.status) {
|
||
case 'pending': statusText = '⏸️ Pending'; break;
|
||
case 'searching': statusText = '🔍 Searching...'; break;
|
||
case 'downloading': statusText = `⏬ Downloading... ${Math.round(task.progress || 0)}%`; break;
|
||
case 'post_processing': statusText = '⌛ Processing...'; break; // NEW VERIFICATION WORKFLOW
|
||
case 'completed': statusText = '✅ Completed'; completedCount++; break;
|
||
case 'failed': statusText = '❌ Failed'; failedOrCancelledCount++; break;
|
||
case 'cancelled': statusText = '🚫 Cancelled'; failedOrCancelledCount++; break;
|
||
default: statusText = `⚪ ${task.status}`; break;
|
||
}
|
||
|
||
if (statusEl) statusEl.textContent = statusText;
|
||
if (actionsEl) actionsEl.innerHTML = '-'; // Remove action buttons for completed tasks
|
||
});
|
||
|
||
// Update download progress to final state
|
||
const totalFinished = completedCount + failedOrCancelledCount;
|
||
const missingCount = missingTracks.length;
|
||
const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 100;
|
||
|
||
const downloadProgressFill = document.getElementById(`download-progress-fill-${playlistId}`);
|
||
const downloadProgressText = document.getElementById(`download-progress-text-${playlistId}`);
|
||
const statDownloaded = document.getElementById(`stat-downloaded-${playlistId}`);
|
||
|
||
if (downloadProgressFill) downloadProgressFill.style.width = `${progressPercent}%`;
|
||
if (downloadProgressText) downloadProgressText.textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`;
|
||
if (statDownloaded) statDownloaded.textContent = completedCount;
|
||
|
||
console.log(`✅ [Completed Results] Updated modal with ${completedCount} completed, ${failedOrCancelledCount} failed tasks`);
|
||
|
||
} catch (error) {
|
||
console.error(`❌ [Completed Results] Error updating completed modal results:`, error);
|
||
}
|
||
}
|
||
|
||
function updateTidalCardPhase(playlistId, phase) {
|
||
const state = tidalPlaylistStates[playlistId];
|
||
if (!state) return;
|
||
|
||
state.phase = phase;
|
||
|
||
// Re-render the card with new phase
|
||
const card = document.getElementById(`tidal-card-${playlistId}`);
|
||
if (card) {
|
||
const oldButtonText = card.querySelector('.playlist-card-action-btn')?.textContent || 'unknown';
|
||
const newCardHtml = createTidalCard(state.playlist);
|
||
card.outerHTML = newCardHtml;
|
||
|
||
// Verify the card was actually updated
|
||
const updatedCard = document.getElementById(`tidal-card-${playlistId}`);
|
||
const newButtonText = updatedCard?.querySelector('.playlist-card-action-btn')?.textContent || 'unknown';
|
||
|
||
console.log(`🔄 [Card Update] Re-rendered Tidal card ${playlistId}:`);
|
||
console.log(` 📊 Phase: ${phase}`);
|
||
console.log(` 🔘 Button text: "${oldButtonText}" → "${newButtonText}"`);
|
||
console.log(` ✅ Expected: "${getActionButtonText(phase)}"`);
|
||
|
||
if (newButtonText !== getActionButtonText(phase)) {
|
||
console.error(`❌ [Card Update] Button text mismatch! Expected "${getActionButtonText(phase)}", got "${newButtonText}"`);
|
||
}
|
||
|
||
// Re-attach click handler
|
||
const newCard = document.getElementById(`tidal-card-${playlistId}`);
|
||
if (newCard) {
|
||
newCard.addEventListener('click', () => handleTidalCardClick(playlistId));
|
||
console.debug(`🔗 [Card Update] Reattached click handler for Tidal card: ${playlistId}`);
|
||
} else {
|
||
console.error(`❌ [Card Update] Failed to find new card after rendering: tidal-card-${playlistId}`);
|
||
}
|
||
|
||
// If we have sync progress and we're in sync/sync_complete phase, restore it
|
||
if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) {
|
||
setTimeout(() => {
|
||
updateTidalCardSyncProgress(playlistId, state.lastSyncProgress);
|
||
}, 0);
|
||
}
|
||
}
|
||
|
||
console.log(`🎵 Updated Tidal card phase: ${playlistId} -> ${phase}`);
|
||
}
|
||
|
||
async function openTidalDiscoveryModal(playlistId, playlistData) {
|
||
console.log(`🎵 Opening Tidal discovery modal (reusing YouTube modal): ${playlistData.name}`);
|
||
|
||
// Create a fake YouTube-style urlHash for the modal system
|
||
const fakeUrlHash = `tidal_${playlistId}`;
|
||
|
||
// Get current Tidal card state to check if discovery is already done or in progress
|
||
const tidalCardState = tidalPlaylistStates[playlistId];
|
||
const isAlreadyDiscovered = tidalCardState && (tidalCardState.phase === 'discovered' || tidalCardState.phase === 'syncing' || tidalCardState.phase === 'sync_complete');
|
||
const isCurrentlyDiscovering = tidalCardState && tidalCardState.phase === 'discovering';
|
||
|
||
// Prepare discovery results in the correct format for modal
|
||
let transformedResults = [];
|
||
let actualMatches = 0;
|
||
if (isAlreadyDiscovered && tidalCardState.discovery_results) {
|
||
transformedResults = tidalCardState.discovery_results.map((result, index) => {
|
||
const isFound = result.status === 'found';
|
||
if (isFound) actualMatches++;
|
||
|
||
return {
|
||
index: index,
|
||
yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown',
|
||
yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown',
|
||
status: isFound ? '✅ Found' : '❌ Not Found',
|
||
status_class: isFound ? 'found' : 'not-found',
|
||
spotify_track: result.spotify_data ? result.spotify_data.name : '-',
|
||
spotify_artist: result.spotify_data ? result.spotify_data.artists.join(', ') : '-',
|
||
spotify_album: result.spotify_data ? result.spotify_data.album : '-'
|
||
};
|
||
});
|
||
console.log(`🎵 Tidal modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`);
|
||
}
|
||
|
||
// Create YouTube-compatible state structure
|
||
const modalPhase = tidalCardState ? tidalCardState.phase : 'fresh';
|
||
youtubePlaylistStates[fakeUrlHash] = {
|
||
phase: modalPhase,
|
||
playlist: {
|
||
name: playlistData.name,
|
||
tracks: playlistData.tracks
|
||
},
|
||
is_tidal_playlist: true, // Flag to identify this as Tidal
|
||
tidal_playlist_id: playlistId,
|
||
discovery_progress: isAlreadyDiscovered ? 100 : 0,
|
||
spotify_matches: isAlreadyDiscovered ? actualMatches : 0, // Backend format (snake_case)
|
||
spotifyMatches: isAlreadyDiscovered ? actualMatches : 0, // Frontend format (camelCase) - for button logic
|
||
spotify_total: playlistData.tracks.length,
|
||
discovery_results: transformedResults,
|
||
discoveryResults: transformedResults, // Both formats for compatibility
|
||
discoveryProgress: isAlreadyDiscovered ? 100 : 0 // Frontend format for modal progress display
|
||
};
|
||
|
||
// Only start discovery if not already discovered AND not currently discovering
|
||
if (!isAlreadyDiscovered && !isCurrentlyDiscovering) {
|
||
// Start Tidal discovery process automatically (like sync.py)
|
||
try {
|
||
console.log(`🔍 Starting Tidal discovery for: ${playlistData.name}`);
|
||
|
||
const response = await fetch(`/api/tidal/discovery/start/${playlistId}`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.error) {
|
||
console.error('❌ Error starting Tidal discovery:', result.error);
|
||
showToast(`Error starting discovery: ${result.error}`, 'error');
|
||
return;
|
||
}
|
||
|
||
console.log('✅ Tidal discovery started, beginning polling...');
|
||
|
||
// Update phase to discovering now that backend discovery is actually started
|
||
tidalPlaylistStates[playlistId].phase = 'discovering';
|
||
updateTidalCardPhase(playlistId, 'discovering');
|
||
|
||
// Update modal phase to match
|
||
youtubePlaylistStates[fakeUrlHash].phase = 'discovering';
|
||
|
||
// Start polling for progress
|
||
startTidalDiscoveryPolling(fakeUrlHash, playlistId);
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error starting Tidal discovery:', error);
|
||
showToast(`Error starting discovery: ${error.message}`, 'error');
|
||
}
|
||
} else if (isCurrentlyDiscovering) {
|
||
// Resume polling if discovery is already in progress (like YouTube)
|
||
console.log(`🔄 Resuming Tidal discovery polling for: ${playlistData.name}`);
|
||
startTidalDiscoveryPolling(fakeUrlHash, playlistId);
|
||
} else if (tidalCardState && tidalCardState.phase === 'syncing') {
|
||
// Resume sync polling if sync is in progress
|
||
console.log(`🔄 Resuming Tidal sync polling for: ${playlistData.name}`);
|
||
startTidalSyncPolling(fakeUrlHash);
|
||
} else {
|
||
console.log('✅ Using existing results - no need to re-discover');
|
||
}
|
||
|
||
// Reuse YouTube discovery modal (exact sync.py pattern)
|
||
openYouTubeDiscoveryModal(fakeUrlHash);
|
||
}
|
||
|
||
function startTidalDiscoveryPolling(fakeUrlHash, playlistId) {
|
||
console.log(`🔄 Starting Tidal discovery polling for: ${playlistId}`);
|
||
|
||
// Stop any existing polling
|
||
if (activeYouTubePollers[fakeUrlHash]) {
|
||
clearInterval(activeYouTubePollers[fakeUrlHash]);
|
||
}
|
||
|
||
const pollInterval = setInterval(async () => {
|
||
try {
|
||
const response = await fetch(`/api/tidal/discovery/status/${playlistId}`);
|
||
const status = await response.json();
|
||
|
||
if (status.error) {
|
||
console.error('❌ Error polling Tidal discovery status:', status.error);
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[fakeUrlHash];
|
||
return;
|
||
}
|
||
|
||
// Transform Tidal results to YouTube modal format first
|
||
const transformedStatus = {
|
||
progress: status.progress,
|
||
spotify_matches: status.spotify_matches,
|
||
spotify_total: status.spotify_total,
|
||
results: status.results.map((result, index) => ({
|
||
index: index,
|
||
yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown',
|
||
yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown',
|
||
status: result.status === 'found' ? '✅ Found' : '❌ Not Found',
|
||
status_class: result.status === 'found' ? 'found' : 'not-found',
|
||
spotify_track: result.spotify_data ? result.spotify_data.name : '-',
|
||
spotify_artist: result.spotify_data ? result.spotify_data.artists.join(', ') : '-',
|
||
spotify_album: result.spotify_data ? result.spotify_data.album : '-'
|
||
}))
|
||
};
|
||
|
||
// Update fake YouTube state with Tidal discovery results
|
||
const state = youtubePlaylistStates[fakeUrlHash];
|
||
if (state) {
|
||
state.discovery_progress = status.progress; // Backend format
|
||
state.discoveryProgress = status.progress; // Frontend format - for modal progress display
|
||
state.spotify_matches = status.spotify_matches; // Backend format
|
||
state.spotifyMatches = status.spotify_matches; // Frontend format - for button logic
|
||
state.discovery_results = status.results; // Backend format
|
||
state.discoveryResults = transformedStatus.results; // Frontend format - for button logic
|
||
state.phase = status.phase;
|
||
|
||
// Update modal with transformed data (reuse YouTube modal update logic)
|
||
updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus);
|
||
|
||
// Update Tidal card phase and save discovery results FIRST
|
||
if (tidalPlaylistStates[playlistId]) {
|
||
tidalPlaylistStates[playlistId].phase = status.phase;
|
||
tidalPlaylistStates[playlistId].discovery_results = status.results;
|
||
tidalPlaylistStates[playlistId].spotify_matches = status.spotify_matches;
|
||
tidalPlaylistStates[playlistId].discovery_progress = status.progress;
|
||
updateTidalCardPhase(playlistId, status.phase);
|
||
}
|
||
|
||
// Update Tidal card progress AFTER phase update to avoid being overwritten
|
||
updateTidalCardProgress(playlistId, status);
|
||
|
||
console.log(`🔄 Tidal discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`);
|
||
}
|
||
|
||
// Stop polling when complete
|
||
if (status.complete) {
|
||
console.log(`✅ Tidal discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`);
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[fakeUrlHash];
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error polling Tidal discovery:', error);
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[fakeUrlHash];
|
||
}
|
||
}, 1000); // Poll every second like YouTube
|
||
|
||
// Store poller reference (reuse YouTube poller storage)
|
||
activeYouTubePollers[fakeUrlHash] = pollInterval;
|
||
}
|
||
|
||
async function loadTidalPlaylistStatesFromBackend() {
|
||
// Load all stored Tidal playlist discovery states from backend (similar to YouTube hydration)
|
||
try {
|
||
console.log('🎵 Loading Tidal playlist states from backend...');
|
||
|
||
const response = await fetch('/api/tidal/playlists/states');
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || 'Failed to fetch Tidal playlist states');
|
||
}
|
||
|
||
const data = await response.json();
|
||
const states = data.states || [];
|
||
|
||
console.log(`🎵 Found ${states.length} stored Tidal playlist states in backend`);
|
||
|
||
if (states.length === 0) {
|
||
console.log('🎵 No Tidal playlist states to hydrate');
|
||
return;
|
||
}
|
||
|
||
// Apply states to existing playlist cards
|
||
for (const stateInfo of states) {
|
||
await applyTidalPlaylistState(stateInfo);
|
||
}
|
||
|
||
// Rehydrate download modals for Tidal playlists in downloading/download_complete phases
|
||
for (const stateInfo of states) {
|
||
if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') &&
|
||
stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) {
|
||
|
||
const convertedPlaylistId = stateInfo.converted_spotify_playlist_id;
|
||
|
||
if (!activeDownloadProcesses[convertedPlaylistId]) {
|
||
console.log(`💧 Rehydrating download modal for Tidal playlist: ${stateInfo.playlist_id}`);
|
||
try {
|
||
// Get the playlist data
|
||
const playlistData = tidalPlaylists.find(p => p.id === stateInfo.playlist_id);
|
||
if (!playlistData) {
|
||
console.warn(`⚠️ Playlist data not found for rehydration: ${stateInfo.playlist_id}`);
|
||
continue;
|
||
}
|
||
|
||
// Create the download modal using the Tidal-specific function
|
||
const spotifyTracks = tidalPlaylistStates[stateInfo.playlist_id]?.discovery_results
|
||
?.filter(result => result.spotify_data)
|
||
?.map(result => result.spotify_data) || [];
|
||
|
||
if (spotifyTracks.length > 0) {
|
||
await openDownloadMissingModalForTidal(
|
||
convertedPlaylistId,
|
||
`[Tidal] ${playlistData.name}`,
|
||
spotifyTracks
|
||
);
|
||
|
||
// Set the modal to running state with the correct batch ID
|
||
const process = activeDownloadProcesses[convertedPlaylistId];
|
||
if (process) {
|
||
process.status = 'running';
|
||
process.batchId = stateInfo.download_process_id;
|
||
|
||
// Update UI to running state
|
||
const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`);
|
||
const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`);
|
||
if (beginBtn) beginBtn.style.display = 'none';
|
||
if (cancelBtn) cancelBtn.style.display = 'inline-block';
|
||
|
||
// Start polling for this process
|
||
startModalDownloadPolling(convertedPlaylistId);
|
||
|
||
console.log(`✅ Rehydrated Tidal download modal for batch ${stateInfo.download_process_id}`);
|
||
}
|
||
} else {
|
||
console.warn(`⚠️ No Spotify tracks found for Tidal playlist rehydration: ${stateInfo.playlist_id}`);
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ Error rehydrating Tidal download modal for ${stateInfo.playlist_id}:`, error);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log('✅ Tidal playlist states loaded and applied');
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error loading Tidal playlist states:', error);
|
||
}
|
||
}
|
||
|
||
async function applyTidalPlaylistState(stateInfo) {
|
||
const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo;
|
||
|
||
try {
|
||
console.log(`🎵 Applying saved state for Tidal playlist: ${playlist_id}, Phase: ${phase}`);
|
||
|
||
// Find the playlist data from the loaded playlists
|
||
const playlistData = tidalPlaylists.find(p => p.id === playlist_id);
|
||
if (!playlistData) {
|
||
console.warn(`⚠️ Playlist data not found for state ${playlist_id} - skipping`);
|
||
return;
|
||
}
|
||
|
||
// Update local state
|
||
if (!tidalPlaylistStates[playlist_id]) {
|
||
// Initialize state if it doesn't exist
|
||
tidalPlaylistStates[playlist_id] = {
|
||
playlist: playlistData,
|
||
phase: 'fresh'
|
||
};
|
||
}
|
||
|
||
// Update with backend state
|
||
tidalPlaylistStates[playlist_id].phase = phase;
|
||
tidalPlaylistStates[playlist_id].discovery_progress = discovery_progress;
|
||
tidalPlaylistStates[playlist_id].spotify_matches = spotify_matches;
|
||
tidalPlaylistStates[playlist_id].discovery_results = discovery_results;
|
||
tidalPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id;
|
||
tidalPlaylistStates[playlist_id].download_process_id = download_process_id;
|
||
tidalPlaylistStates[playlist_id].playlist = playlistData; // Ensure playlist data is set
|
||
|
||
// Fetch full discovery results for non-fresh playlists (matching YouTube pattern)
|
||
if (phase !== 'fresh' && phase !== 'discovering') {
|
||
try {
|
||
console.log(`🔍 Fetching full discovery results for Tidal playlist: ${playlistData.name}`);
|
||
const stateResponse = await fetch(`/api/tidal/state/${playlist_id}`);
|
||
if (stateResponse.ok) {
|
||
const fullState = await stateResponse.json();
|
||
console.log(`📋 Retrieved full Tidal state with ${fullState.discovery_results?.length || 0} discovery results`);
|
||
|
||
// Store full discovery results in local state (matching YouTube pattern)
|
||
if (fullState.discovery_results && tidalPlaylistStates[playlist_id]) {
|
||
tidalPlaylistStates[playlist_id].discovery_results = fullState.discovery_results;
|
||
tidalPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress;
|
||
tidalPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches;
|
||
tidalPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id;
|
||
tidalPlaylistStates[playlist_id].download_process_id = fullState.download_process_id;
|
||
console.log(`✅ Restored ${fullState.discovery_results.length} discovery results for Tidal playlist: ${playlistData.name}`);
|
||
}
|
||
} else {
|
||
console.warn(`⚠️ Could not fetch full discovery results for Tidal playlist: ${playlistData.name}`);
|
||
}
|
||
} catch (error) {
|
||
console.warn(`⚠️ Error fetching full discovery results for Tidal playlist ${playlistData.name}:`, error.message);
|
||
}
|
||
}
|
||
|
||
// Update the card UI to reflect the saved state
|
||
updateTidalCardPhase(playlist_id, phase);
|
||
|
||
// Update card progress if we have discovery results
|
||
if (phase === 'discovered' && tidalPlaylistStates[playlist_id]) {
|
||
const progressInfo = {
|
||
spotify_total: playlistData.track_count || playlistData.tracks?.length || 0,
|
||
spotify_matches: tidalPlaylistStates[playlist_id].spotify_matches || 0
|
||
};
|
||
updateTidalCardProgress(playlist_id, progressInfo);
|
||
}
|
||
|
||
console.log(`✅ Applied saved state for Tidal playlist: ${playlist_id} -> ${phase}`);
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error applying Tidal playlist state for ${playlist_id}:`, error);
|
||
}
|
||
}
|
||
|
||
function updateTidalCardProgress(playlistId, progress) {
|
||
const state = tidalPlaylistStates[playlistId];
|
||
if (!state) return;
|
||
|
||
const card = document.getElementById(`tidal-card-${playlistId}`);
|
||
if (!card) return;
|
||
|
||
const progressElement = card.querySelector('.playlist-card-progress');
|
||
if (!progressElement) return;
|
||
|
||
const total = progress.spotify_total || 0;
|
||
const matches = progress.spotify_matches || 0;
|
||
const failed = total - matches;
|
||
const percentage = total > 0 ? Math.round((matches / total) * 100) : 0;
|
||
|
||
progressElement.textContent = `♪ ${total} / ✓ ${matches} / ✗ ${failed} / ${percentage}%`;
|
||
progressElement.classList.remove('hidden'); // Show progress during discovery
|
||
|
||
console.log('🎵 Updated Tidal card progress:', playlistId, `${matches}/${total} (${percentage}%)`);
|
||
}
|
||
|
||
// ===============================
|
||
// TIDAL SYNC FUNCTIONALITY
|
||
// ===============================
|
||
|
||
async function startTidalPlaylistSync(urlHash) {
|
||
try {
|
||
console.log('🎵 Starting Tidal playlist sync:', urlHash);
|
||
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.is_tidal_playlist) {
|
||
console.error('❌ Invalid Tidal playlist state for sync');
|
||
return;
|
||
}
|
||
|
||
const playlistId = state.tidal_playlist_id;
|
||
const response = await fetch(`/api/tidal/sync/start/${playlistId}`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.error) {
|
||
showToast(`Error starting sync: ${result.error}`, 'error');
|
||
return;
|
||
}
|
||
|
||
// Update card and modal to syncing phase
|
||
updateTidalCardPhase(playlistId, 'syncing');
|
||
|
||
// Update modal buttons if modal is open
|
||
updateTidalModalButtons(urlHash, 'syncing');
|
||
|
||
// Start sync polling
|
||
startTidalSyncPolling(urlHash);
|
||
|
||
showToast('Tidal playlist sync started!', 'success');
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error starting Tidal sync:', error);
|
||
showToast(`Error starting sync: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function startTidalSyncPolling(urlHash) {
|
||
// Stop any existing polling
|
||
if (activeYouTubePollers[urlHash]) {
|
||
clearInterval(activeYouTubePollers[urlHash]);
|
||
}
|
||
|
||
const state = youtubePlaylistStates[urlHash];
|
||
const playlistId = state.tidal_playlist_id;
|
||
|
||
const pollInterval = setInterval(async () => {
|
||
try {
|
||
const response = await fetch(`/api/tidal/sync/status/${playlistId}`);
|
||
const status = await response.json();
|
||
|
||
if (status.error) {
|
||
console.error('❌ Error polling Tidal sync status:', status.error);
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
return;
|
||
}
|
||
|
||
// Update card progress with sync stats
|
||
updateTidalCardSyncProgress(playlistId, status.progress);
|
||
|
||
// Update modal sync display if open
|
||
updateTidalModalSyncProgress(urlHash, status.progress);
|
||
|
||
// Check if complete
|
||
if (status.complete) {
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
|
||
// Update both states to sync_complete
|
||
if (tidalPlaylistStates[playlistId]) {
|
||
tidalPlaylistStates[playlistId].phase = 'sync_complete';
|
||
}
|
||
if (youtubePlaylistStates[urlHash]) {
|
||
youtubePlaylistStates[urlHash].phase = 'sync_complete';
|
||
}
|
||
|
||
// Update card phase to sync complete
|
||
updateTidalCardPhase(playlistId, 'sync_complete');
|
||
|
||
// Update modal buttons
|
||
updateTidalModalButtons(urlHash, 'sync_complete');
|
||
|
||
console.log('✅ Tidal sync complete:', urlHash);
|
||
showToast('Tidal playlist sync complete!', 'success');
|
||
} else if (status.sync_status === 'error') {
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
|
||
// Update both states to discovered (revert on error)
|
||
if (tidalPlaylistStates[playlistId]) {
|
||
tidalPlaylistStates[playlistId].phase = 'discovered';
|
||
}
|
||
if (youtubePlaylistStates[urlHash]) {
|
||
youtubePlaylistStates[urlHash].phase = 'discovered';
|
||
}
|
||
|
||
// Revert to discovered phase on error
|
||
updateTidalCardPhase(playlistId, 'discovered');
|
||
updateTidalModalButtons(urlHash, 'discovered');
|
||
|
||
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error polling Tidal sync:', error);
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
}
|
||
}, 1000);
|
||
|
||
activeYouTubePollers[urlHash] = pollInterval;
|
||
}
|
||
|
||
async function cancelTidalSync(urlHash) {
|
||
try {
|
||
console.log('❌ Cancelling Tidal sync:', urlHash);
|
||
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.is_tidal_playlist) {
|
||
console.error('❌ Invalid Tidal playlist state');
|
||
return;
|
||
}
|
||
|
||
const playlistId = state.tidal_playlist_id;
|
||
const response = await fetch(`/api/tidal/sync/cancel/${playlistId}`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.error) {
|
||
showToast(`Error cancelling sync: ${result.error}`, 'error');
|
||
return;
|
||
}
|
||
|
||
// Stop polling
|
||
if (activeYouTubePollers[urlHash]) {
|
||
clearInterval(activeYouTubePollers[urlHash]);
|
||
delete activeYouTubePollers[urlHash];
|
||
}
|
||
|
||
// Revert to discovered phase
|
||
updateTidalCardPhase(playlistId, 'discovered');
|
||
updateTidalModalButtons(urlHash, 'discovered');
|
||
|
||
showToast('Tidal sync cancelled', 'info');
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error cancelling Tidal sync:', error);
|
||
showToast(`Error cancelling sync: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function updateTidalCardSyncProgress(playlistId, progress) {
|
||
const state = tidalPlaylistStates[playlistId];
|
||
if (!state || !state.playlist || !progress) return;
|
||
|
||
// Save the progress for later restoration
|
||
state.lastSyncProgress = progress;
|
||
|
||
const card = document.getElementById(`tidal-card-${playlistId}`);
|
||
if (!card) return;
|
||
|
||
const progressElement = card.querySelector('.playlist-card-progress');
|
||
|
||
// Build clean status counter HTML exactly like YouTube cards
|
||
let statusCounterHTML = '';
|
||
if (progress && progress.total_tracks > 0) {
|
||
const matched = progress.matched_tracks || 0;
|
||
const failed = progress.failed_tracks || 0;
|
||
const total = progress.total_tracks || 0;
|
||
const processed = matched + failed;
|
||
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||
|
||
statusCounterHTML = `
|
||
<div class="playlist-card-sync-status">
|
||
<span class="sync-stat total-tracks">♪ ${total}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ ${matched}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ ${failed}</span>
|
||
<span class="sync-stat percentage">(${percentage}%)</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Only update if we have valid sync progress, otherwise preserve existing discovery results
|
||
if (statusCounterHTML) {
|
||
progressElement.innerHTML = statusCounterHTML;
|
||
}
|
||
|
||
console.log(`🎵 Updated Tidal card sync progress: ♪ ${progress?.total_tracks || 0} / ✓ ${progress?.matched_tracks || 0} / ✗ ${progress?.failed_tracks || 0}`);
|
||
}
|
||
|
||
function updateTidalModalSyncProgress(urlHash, progress) {
|
||
const statusDisplay = document.getElementById(`tidal-sync-status-${urlHash}`);
|
||
if (!statusDisplay || !progress) return;
|
||
|
||
console.log(`📊 Updating Tidal modal sync progress for ${urlHash}:`, progress);
|
||
|
||
// Update individual counters exactly like YouTube sync
|
||
const totalEl = document.getElementById(`tidal-total-${urlHash}`);
|
||
const matchedEl = document.getElementById(`tidal-matched-${urlHash}`);
|
||
const failedEl = document.getElementById(`tidal-failed-${urlHash}`);
|
||
const percentageEl = document.getElementById(`tidal-percentage-${urlHash}`);
|
||
|
||
const total = progress.total_tracks || 0;
|
||
const matched = progress.matched_tracks || 0;
|
||
const failed = progress.failed_tracks || 0;
|
||
|
||
if (totalEl) totalEl.textContent = total;
|
||
if (matchedEl) matchedEl.textContent = matched;
|
||
if (failedEl) failedEl.textContent = failed;
|
||
|
||
// Calculate percentage like YouTube sync
|
||
if (total > 0) {
|
||
const processed = matched + failed;
|
||
const percentage = Math.round((processed / total) * 100);
|
||
if (percentageEl) percentageEl.textContent = percentage;
|
||
}
|
||
|
||
console.log(`📊 Tidal modal updated: ♪ ${total} / ✓ ${matched} / ✗ ${failed} (${Math.round((matched + failed) / total * 100)}%)`);
|
||
}
|
||
|
||
function updateTidalModalButtons(urlHash, phase) {
|
||
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
|
||
if (!modal) return;
|
||
|
||
const footerLeft = modal.querySelector('.modal-footer-left');
|
||
if (footerLeft) {
|
||
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
|
||
}
|
||
}
|
||
|
||
async function startTidalDownloadMissing(urlHash) {
|
||
try {
|
||
console.log('🔍 Starting download missing tracks for Tidal playlist:', urlHash);
|
||
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.is_tidal_playlist) {
|
||
console.error('❌ Invalid Tidal playlist state for download');
|
||
return;
|
||
}
|
||
|
||
// Get the actual Tidal playlist ID
|
||
const tidalPlaylistId = state.tidal_playlist_id;
|
||
const tidalState = tidalPlaylistStates[tidalPlaylistId];
|
||
|
||
if (!tidalState || !tidalState.discovery_results) {
|
||
showToast('No discovery results available for download', 'error');
|
||
return;
|
||
}
|
||
|
||
// Convert Tidal discovery results to Spotify tracks format (same as YouTube)
|
||
const spotifyTracks = [];
|
||
for (const result of tidalState.discovery_results) {
|
||
if (result.spotify_data) {
|
||
spotifyTracks.push(result.spotify_data);
|
||
}
|
||
}
|
||
|
||
if (spotifyTracks.length === 0) {
|
||
showToast('No Spotify matches found for download', 'error');
|
||
return;
|
||
}
|
||
|
||
// Create a virtual playlist for the download system
|
||
const virtualPlaylistId = `tidal_${tidalPlaylistId}`;
|
||
const playlistName = `[Tidal] ${tidalState.playlist.name}`;
|
||
|
||
// Store reference for card navigation (same as YouTube)
|
||
tidalState.convertedSpotifyPlaylistId = virtualPlaylistId;
|
||
state.convertedSpotifyPlaylistId = virtualPlaylistId;
|
||
|
||
// Close the discovery modal if it's open (same as YouTube)
|
||
const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
|
||
if (discoveryModal) {
|
||
discoveryModal.classList.add('hidden');
|
||
console.log('🔄 Closed Tidal discovery modal to show download modal');
|
||
}
|
||
|
||
// Open download missing tracks modal for Tidal playlist
|
||
await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks);
|
||
|
||
// Phase will change to 'downloading' when user clicks "Begin Analysis" button
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error starting download missing tracks:', error);
|
||
showToast(`Error starting downloads: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks) {
|
||
// Check if a process is already active for this virtual playlist
|
||
if (activeDownloadProcesses[virtualPlaylistId]) {
|
||
console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`);
|
||
const process = activeDownloadProcesses[virtualPlaylistId];
|
||
if (process.modalElement) {
|
||
if (process.status === 'complete') {
|
||
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
|
||
}
|
||
process.modalElement.style.display = 'flex';
|
||
}
|
||
return;
|
||
}
|
||
|
||
console.log(`📥 Opening Download Missing Tracks modal for Tidal playlist: ${virtualPlaylistId}`);
|
||
|
||
// Create virtual playlist object for compatibility with existing modal logic
|
||
const virtualPlaylist = {
|
||
id: virtualPlaylistId,
|
||
name: playlistName,
|
||
track_count: spotifyTracks.length
|
||
};
|
||
|
||
// Store the tracks in the cache for the modal to use
|
||
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
|
||
currentPlaylistTracks = spotifyTracks;
|
||
currentModalPlaylistId = virtualPlaylistId;
|
||
|
||
let modal = document.createElement('div');
|
||
modal.id = `download-missing-modal-${virtualPlaylistId}`;
|
||
modal.className = 'download-missing-modal';
|
||
modal.style.display = 'none';
|
||
document.body.appendChild(modal);
|
||
|
||
// Register the new process in our global state tracker using the same structure as Spotify
|
||
activeDownloadProcesses[virtualPlaylistId] = {
|
||
status: 'idle',
|
||
modalElement: modal,
|
||
poller: null,
|
||
batchId: null,
|
||
playlist: virtualPlaylist,
|
||
tracks: spotifyTracks
|
||
};
|
||
|
||
// Use the exact same modal HTML structure as the existing Spotify modal
|
||
modal.innerHTML = `
|
||
<div class="download-missing-modal-content">
|
||
<div class="download-missing-modal-header">
|
||
<h2 class="download-missing-modal-title">Download Missing Tracks - ${escapeHtml(playlistName)}</h2>
|
||
<span class="download-missing-modal-close" onclick="closeDownloadMissingModal('${virtualPlaylistId}')">×</span>
|
||
</div>
|
||
|
||
<div class="download-missing-modal-body">
|
||
<div class="download-dashboard-stats">
|
||
<div class="dashboard-stat stat-total">
|
||
<div class="dashboard-stat-number" id="stat-total-${virtualPlaylistId}">${spotifyTracks.length}</div>
|
||
<div class="dashboard-stat-label">Total Tracks</div>
|
||
</div>
|
||
<div class="dashboard-stat stat-found">
|
||
<div class="dashboard-stat-number" id="stat-found-${virtualPlaylistId}">-</div>
|
||
<div class="dashboard-stat-label">Found in Library</div>
|
||
</div>
|
||
<div class="dashboard-stat stat-missing">
|
||
<div class="dashboard-stat-number" id="stat-missing-${virtualPlaylistId}">-</div>
|
||
<div class="dashboard-stat-label">Missing Tracks</div>
|
||
</div>
|
||
<div class="dashboard-stat stat-downloaded">
|
||
<div class="dashboard-stat-number" id="stat-downloaded-${virtualPlaylistId}">0</div>
|
||
<div class="dashboard-stat-label">Downloaded</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-progress-section">
|
||
<div class="progress-item">
|
||
<div class="progress-label">
|
||
🔍 Library Analysis
|
||
<span id="analysis-progress-text-${virtualPlaylistId}">Ready to start</span>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill analysis" id="analysis-progress-fill-${virtualPlaylistId}"></div>
|
||
</div>
|
||
</div>
|
||
<div class="progress-item">
|
||
<div class="progress-label">
|
||
⏬ Downloads
|
||
<span id="download-progress-text-${virtualPlaylistId}">Waiting for analysis</span>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill download" id="download-progress-fill-${virtualPlaylistId}"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-tracks-section">
|
||
<div class="download-tracks-header">
|
||
<h3 class="download-tracks-title">📋 Track Analysis & Download Status</h3>
|
||
</div>
|
||
<div class="download-tracks-table-container">
|
||
<table class="download-tracks-table">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Track</th>
|
||
<th>Artist</th>
|
||
<th>Duration</th>
|
||
<th>Library Match</th>
|
||
<th>Download Status</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="download-tracks-tbody-${virtualPlaylistId}">
|
||
${spotifyTracks.map((track, index) => `
|
||
<tr data-track-index="${index}">
|
||
<td class="track-number">${index + 1}</td>
|
||
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
|
||
<td class="track-artist" title="${escapeHtml(track.artists.join(', '))}">${track.artists.join(', ')}</td>
|
||
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
|
||
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
|
||
<td class="track-download-status" id="download-${virtualPlaylistId}-${index}">-</td>
|
||
<td class="track-actions" id="actions-${virtualPlaylistId}-${index}">-</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-missing-modal-footer">
|
||
<div class="download-phase-controls">
|
||
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
|
||
Begin Analysis
|
||
</button>
|
||
<button class="download-control-btn danger" id="cancel-all-btn-${virtualPlaylistId}" onclick="cancelAllOperations('${virtualPlaylistId}')" style="display: none;">
|
||
Cancel All
|
||
</button>
|
||
</div>
|
||
<div class="modal-close-section">
|
||
<button class="download-control-btn secondary" onclick="closeDownloadMissingModal('${virtualPlaylistId}')">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
modal.style.display = 'flex';
|
||
}
|
||
|
||
|
||
// ===============================
|
||
// SYNC PAGE FUNCTIONALITY (REDESIGNED)
|
||
// ===============================
|
||
|
||
function initializeSyncPage() {
|
||
// Logic for tab switching
|
||
const tabButtons = document.querySelectorAll('.sync-tab-button');
|
||
tabButtons.forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const tabId = button.dataset.tab;
|
||
|
||
// Update button active state
|
||
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||
button.classList.add('active');
|
||
|
||
// Update content active state
|
||
document.querySelectorAll('.sync-tab-content').forEach(content => {
|
||
content.classList.remove('active');
|
||
});
|
||
document.getElementById(`${tabId}-tab-content`).classList.add('active');
|
||
});
|
||
});
|
||
|
||
// Logic for the Spotify refresh button
|
||
const refreshBtn = document.getElementById('spotify-refresh-btn');
|
||
if (refreshBtn) {
|
||
// Remove any old listeners to be safe, then add the new one
|
||
refreshBtn.removeEventListener('click', loadSpotifyPlaylists);
|
||
refreshBtn.addEventListener('click', loadSpotifyPlaylists);
|
||
}
|
||
|
||
// Logic for the Tidal refresh button
|
||
const tidalRefreshBtn = document.getElementById('tidal-refresh-btn');
|
||
if (tidalRefreshBtn) {
|
||
tidalRefreshBtn.removeEventListener('click', loadTidalPlaylists);
|
||
tidalRefreshBtn.addEventListener('click', loadTidalPlaylists);
|
||
}
|
||
|
||
// Logic for the Start Sync button
|
||
const startSyncBtn = document.getElementById('start-sync-btn');
|
||
if (startSyncBtn) {
|
||
startSyncBtn.addEventListener('click', startSequentialSync);
|
||
}
|
||
|
||
// Logic for the YouTube parse button
|
||
const youtubeParseBtn = document.getElementById('youtube-parse-btn');
|
||
if (youtubeParseBtn) {
|
||
youtubeParseBtn.addEventListener('click', parseYouTubePlaylist);
|
||
}
|
||
|
||
// Logic for YouTube URL input (Enter key support)
|
||
const youtubeUrlInput = document.getElementById('youtube-url-input');
|
||
if (youtubeUrlInput) {
|
||
youtubeUrlInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') {
|
||
parseYouTubePlaylist();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
|
||
// --- Event Handlers ---
|
||
|
||
// --- Find and REPLACE the existing handleDbUpdateButtonClick function ---
|
||
|
||
async function handleDbUpdateButtonClick() {
|
||
const button = document.getElementById('db-update-button');
|
||
const currentAction = button.textContent;
|
||
|
||
if (currentAction === 'Update Database') {
|
||
const refreshSelect = document.getElementById('db-refresh-type');
|
||
const isFullRefresh = refreshSelect.value === 'full';
|
||
|
||
if (isFullRefresh) {
|
||
// Replicates the QMessageBox confirmation from the GUI
|
||
const confirmed = confirm("⚠️ Full Refresh Warning!\n\nThis will clear and rebuild the database for the active server. It can take a long time. Are you sure you want to proceed?");
|
||
if (!confirmed) return;
|
||
}
|
||
|
||
try {
|
||
button.disabled = true;
|
||
button.textContent = 'Starting...';
|
||
const response = await fetch('/api/database/update', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ full_refresh: isFullRefresh })
|
||
});
|
||
|
||
if (response.ok) {
|
||
showToast('Database update started!', 'success');
|
||
// Start polling immediately to get live status
|
||
checkAndUpdateDbProgress();
|
||
} else {
|
||
const errorData = await response.json();
|
||
showToast(`Error: ${errorData.error}`, 'error');
|
||
button.disabled = false;
|
||
button.textContent = 'Update Database';
|
||
}
|
||
} catch (error) {
|
||
showToast('Failed to start update process.', 'error');
|
||
button.disabled = false;
|
||
button.textContent = 'Update Database';
|
||
}
|
||
|
||
} else { // "Stop Update"
|
||
try {
|
||
const response = await fetch('/api/database/update/stop', { method: 'POST' });
|
||
if (response.ok) {
|
||
showToast('Stop request sent.', 'info');
|
||
} else {
|
||
showToast('Failed to send stop request.', 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('Error sending stop request.', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleWishlistButtonClick() {
|
||
try {
|
||
const playlistId = 'wishlist';
|
||
|
||
console.log('🎵 [Wishlist Button] User clicked wishlist button - checking server state first');
|
||
|
||
// STEP 1: Always check server state first to detect any active wishlist processes
|
||
const response = await fetch('/api/active-processes');
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch active processes: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
const processes = data.active_processes || [];
|
||
const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId);
|
||
|
||
// STEP 2: Handle active server process - show current state immediately
|
||
if (serverWishlistProcess) {
|
||
console.log('🎯 [Wishlist Button] Server has active wishlist process:', {
|
||
batch_id: serverWishlistProcess.batch_id,
|
||
phase: serverWishlistProcess.phase,
|
||
auto_initiated: serverWishlistProcess.auto_initiated,
|
||
should_show: serverWishlistProcess.should_show_modal
|
||
});
|
||
|
||
// Clear any user-closed state since user explicitly requested to see modal
|
||
WishlistModalState.clearUserClosed();
|
||
|
||
// Check if we need to create/sync the frontend modal
|
||
const clientWishlistProcess = activeDownloadProcesses[playlistId];
|
||
const needsRehydration = !clientWishlistProcess ||
|
||
clientWishlistProcess.batchId !== serverWishlistProcess.batch_id ||
|
||
!clientWishlistProcess.modalElement ||
|
||
!document.body.contains(clientWishlistProcess.modalElement);
|
||
|
||
if (needsRehydration) {
|
||
console.log('🔄 [Wishlist Button] Frontend modal needs sync/creation');
|
||
await rehydrateModal(serverWishlistProcess, true); // user-requested = true
|
||
} else {
|
||
console.log('✅ [Wishlist Button] Frontend modal already synced, showing existing modal');
|
||
clientWishlistProcess.modalElement.style.display = 'flex';
|
||
WishlistModalState.setVisible();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// STEP 3: No active server process - check wishlist count and create fresh modal
|
||
console.log('📭 [Wishlist Button] No active server process, checking wishlist content');
|
||
|
||
const countResponse = await fetch('/api/wishlist/count');
|
||
if (!countResponse.ok) {
|
||
throw new Error(`Failed to fetch wishlist count: ${countResponse.status}`);
|
||
}
|
||
|
||
const countData = await countResponse.json();
|
||
if (countData.count === 0) {
|
||
showToast('Wishlist is empty. No tracks to download.', 'info');
|
||
return;
|
||
}
|
||
|
||
// STEP 4: Create fresh modal for new wishlist process
|
||
console.log(`🆕 [Wishlist Button] Creating fresh modal for ${countData.count} wishlist tracks`);
|
||
await openDownloadMissingWishlistModal();
|
||
|
||
} catch (error) {
|
||
console.error('❌ [Wishlist Button] Error handling wishlist button click:', error);
|
||
showToast(`Error opening wishlist: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function clearWishlist(playlistId) {
|
||
try {
|
||
// Show confirmation dialog
|
||
const confirmed = confirm(
|
||
"Clear Wishlist\n\n" +
|
||
"Are you sure you want to clear the entire wishlist?\n\n" +
|
||
"This will permanently remove all failed tracks from the wishlist. " +
|
||
"This action cannot be undone."
|
||
);
|
||
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
// Disable the clear button during the operation
|
||
const clearBtn = document.getElementById(`clear-wishlist-btn-${playlistId}`);
|
||
if (clearBtn) {
|
||
clearBtn.disabled = true;
|
||
clearBtn.textContent = 'Clearing...';
|
||
}
|
||
|
||
// Call the clear API endpoint
|
||
const response = await fetch('/api/wishlist/clear', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showToast('Wishlist cleared successfully', 'success');
|
||
|
||
// Close the modal since there are no more tracks
|
||
closeDownloadMissingModal(playlistId);
|
||
|
||
// Update the wishlist count in the main dashboard
|
||
await updateWishlistCount();
|
||
|
||
} else {
|
||
showToast(`Failed to clear wishlist: ${result.error || 'Unknown error'}`, 'error');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error clearing wishlist:', error);
|
||
showToast(`Error clearing wishlist: ${error.message}`, 'error');
|
||
} finally {
|
||
// Re-enable the clear button
|
||
const clearBtn = document.getElementById(`clear-wishlist-btn-${playlistId}`);
|
||
if (clearBtn) {
|
||
clearBtn.disabled = false;
|
||
clearBtn.textContent = '🗑️ Clear Wishlist';
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// ===============================
|
||
// YOUTUBE PLAYLIST FUNCTIONALITY
|
||
// ===============================
|
||
|
||
async function parseYouTubePlaylist() {
|
||
const urlInput = document.getElementById('youtube-url-input');
|
||
const url = urlInput.value.trim();
|
||
|
||
if (!url) {
|
||
showToast('Please enter a YouTube playlist URL', 'error');
|
||
return;
|
||
}
|
||
|
||
// Validate URL format
|
||
if (!url.includes('youtube.com/playlist') && !url.includes('music.youtube.com/playlist')) {
|
||
showToast('Please enter a valid YouTube playlist URL', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.log('🎬 Parsing YouTube playlist:', url);
|
||
|
||
// Create card immediately in 'fresh' phase
|
||
createYouTubeCard(url, 'fresh');
|
||
|
||
// Parse playlist via API
|
||
const response = await fetch('/api/youtube/parse', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ url: url })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.error) {
|
||
showToast(`Error parsing YouTube playlist: ${result.error}`, 'error');
|
||
removeYouTubeCard(url);
|
||
return;
|
||
}
|
||
|
||
console.log('✅ YouTube playlist parsed:', result.name, `(${result.tracks.length} tracks)`);
|
||
|
||
// Update card with parsed data and stay in 'fresh' phase
|
||
updateYouTubeCardData(result.url_hash, result);
|
||
updateYouTubeCardPhase(result.url_hash, 'fresh');
|
||
|
||
// Clear input
|
||
urlInput.value = '';
|
||
|
||
// Show success message
|
||
showToast(`YouTube playlist parsed: ${result.name} (${result.tracks.length} tracks)`, 'success');
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error parsing YouTube playlist:', error);
|
||
showToast(`Error parsing YouTube playlist: ${error.message}`, 'error');
|
||
removeYouTubeCard(url);
|
||
}
|
||
}
|
||
|
||
function createYouTubeCard(url, phase = 'fresh') {
|
||
const container = document.getElementById('youtube-playlist-container');
|
||
const placeholder = container.querySelector('.playlist-placeholder');
|
||
|
||
// Remove placeholder if it exists
|
||
if (placeholder) {
|
||
placeholder.style.display = 'none';
|
||
}
|
||
|
||
// Create temporary URL hash for initial card
|
||
const tempHash = btoa(url).substring(0, 8);
|
||
|
||
const cardHtml = `
|
||
<div class="youtube-playlist-card" id="youtube-card-${tempHash}" data-url="${url}">
|
||
<div class="playlist-card-icon youtube-icon">▶</div>
|
||
<div class="playlist-card-content">
|
||
<div class="playlist-card-name">Parsing YouTube playlist...</div>
|
||
<div class="playlist-card-info">
|
||
<span class="playlist-card-track-count">-- tracks</span>
|
||
<span class="playlist-card-phase-text" style="color: #999;">Loading...</span>
|
||
</div>
|
||
</div>
|
||
<div class="playlist-card-progress hidden">
|
||
♪ 0 / ✓ 0 / ✗ 0 / 0%
|
||
</div>
|
||
<button class="playlist-card-action-btn" disabled>Parsing...</button>
|
||
</div>
|
||
`;
|
||
|
||
container.insertAdjacentHTML('beforeend', cardHtml);
|
||
|
||
// Store temporary state
|
||
youtubePlaylistStates[tempHash] = {
|
||
phase: phase,
|
||
url: url,
|
||
cardElement: document.getElementById(`youtube-card-${tempHash}`),
|
||
tempHash: tempHash
|
||
};
|
||
|
||
console.log('🃏 Created YouTube card for URL:', url);
|
||
}
|
||
|
||
function updateYouTubeCardData(urlHash, playlistData) {
|
||
// Find the card by URL or temp hash
|
||
let state = youtubePlaylistStates[urlHash];
|
||
if (!state) {
|
||
// Look for temporary card by URL
|
||
const tempState = Object.values(youtubePlaylistStates).find(s => s.url === playlistData.url);
|
||
if (tempState) {
|
||
// Update the state with real hash
|
||
delete youtubePlaylistStates[tempState.tempHash];
|
||
youtubePlaylistStates[urlHash] = tempState;
|
||
state = tempState;
|
||
|
||
// Update card ID
|
||
if (state.cardElement) {
|
||
state.cardElement.id = `youtube-card-${urlHash}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!state || !state.cardElement) {
|
||
console.error('❌ Could not find YouTube card for hash:', urlHash);
|
||
return;
|
||
}
|
||
|
||
const card = state.cardElement;
|
||
|
||
// Update card content
|
||
const nameElement = card.querySelector('.playlist-card-name');
|
||
const trackCountElement = card.querySelector('.playlist-card-track-count');
|
||
|
||
nameElement.textContent = playlistData.name;
|
||
trackCountElement.textContent = `${playlistData.tracks.length} tracks`;
|
||
|
||
// Store playlist data
|
||
state.playlist = playlistData;
|
||
state.urlHash = urlHash;
|
||
|
||
// Add click handler for card and action button
|
||
const handleCardClick = () => handleYouTubeCardClick(urlHash);
|
||
const actionBtn = card.querySelector('.playlist-card-action-btn');
|
||
|
||
card.addEventListener('click', handleCardClick);
|
||
actionBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation(); // Prevent card click
|
||
handleCardClick();
|
||
});
|
||
|
||
console.log('🃏 Updated YouTube card data:', playlistData.name);
|
||
}
|
||
|
||
function updateYouTubeCardPhase(urlHash, phase) {
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.cardElement) return;
|
||
|
||
const card = state.cardElement;
|
||
const phaseTextElement = card.querySelector('.playlist-card-phase-text');
|
||
const actionBtn = card.querySelector('.playlist-card-action-btn');
|
||
const progressElement = card.querySelector('.playlist-card-progress');
|
||
|
||
state.phase = phase;
|
||
|
||
switch (phase) {
|
||
case 'fresh':
|
||
phaseTextElement.textContent = 'Ready to discover';
|
||
phaseTextElement.style.color = '#999';
|
||
actionBtn.textContent = 'Start Discovery';
|
||
actionBtn.disabled = false;
|
||
progressElement.classList.add('hidden');
|
||
break;
|
||
|
||
case 'discovering':
|
||
phaseTextElement.textContent = 'Discovering...';
|
||
phaseTextElement.style.color = '#ffa500'; // Orange
|
||
actionBtn.textContent = 'View Progress';
|
||
actionBtn.disabled = false;
|
||
progressElement.classList.remove('hidden');
|
||
break;
|
||
|
||
case 'discovered':
|
||
phaseTextElement.textContent = 'Discovery Complete';
|
||
phaseTextElement.style.color = '#1db954'; // Green
|
||
actionBtn.textContent = 'View Details';
|
||
actionBtn.disabled = false;
|
||
progressElement.classList.add('hidden');
|
||
break;
|
||
|
||
case 'syncing':
|
||
phaseTextElement.textContent = 'Syncing...';
|
||
phaseTextElement.style.color = '#ffa500'; // Orange
|
||
actionBtn.textContent = 'View Progress';
|
||
actionBtn.disabled = false;
|
||
progressElement.classList.remove('hidden');
|
||
break;
|
||
|
||
case 'sync_complete':
|
||
phaseTextElement.textContent = 'Sync Complete';
|
||
phaseTextElement.style.color = '#1db954'; // Green
|
||
actionBtn.textContent = 'View Details';
|
||
actionBtn.disabled = false;
|
||
progressElement.classList.add('hidden');
|
||
break;
|
||
|
||
case 'downloading':
|
||
phaseTextElement.textContent = 'Downloading...';
|
||
phaseTextElement.style.color = '#ffa500'; // Orange
|
||
actionBtn.textContent = 'View Downloads';
|
||
actionBtn.disabled = false;
|
||
progressElement.classList.remove('hidden');
|
||
break;
|
||
|
||
case 'download_complete':
|
||
phaseTextElement.textContent = 'Download Complete';
|
||
phaseTextElement.style.color = '#1db954'; // Green
|
||
actionBtn.textContent = 'View Results';
|
||
actionBtn.disabled = false;
|
||
progressElement.classList.add('hidden');
|
||
break;
|
||
}
|
||
|
||
console.log('🃏 Updated YouTube card phase:', urlHash, phase);
|
||
}
|
||
|
||
function handleYouTubeCardClick(urlHash) {
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state) return;
|
||
|
||
switch (state.phase) {
|
||
case 'fresh':
|
||
// First click: Start discovery and open modal
|
||
console.log('🎬 Starting YouTube discovery for first time:', urlHash);
|
||
updateYouTubeCardPhase(urlHash, 'discovering');
|
||
startYouTubeDiscovery(urlHash);
|
||
openYouTubeDiscoveryModal(urlHash);
|
||
break;
|
||
|
||
case 'discovering':
|
||
case 'discovered':
|
||
case 'syncing':
|
||
case 'sync_complete':
|
||
// Open discovery modal with current state
|
||
console.log('🎬 Opening YouTube discovery modal:', urlHash);
|
||
openYouTubeDiscoveryModal(urlHash);
|
||
break;
|
||
|
||
case 'downloading':
|
||
case 'download_complete':
|
||
// Open download missing tracks modal
|
||
console.log('🎬 Opening download modal for YouTube playlist:', urlHash);
|
||
// Need to get playlist ID from converted Spotify data
|
||
const spotifyPlaylistId = state.convertedSpotifyPlaylistId;
|
||
if (spotifyPlaylistId) {
|
||
// Check if we have discovery results, if not load them first
|
||
if (!state.discoveryResults || state.discoveryResults.length === 0) {
|
||
console.log('🔍 Loading discovery results for download modal...');
|
||
fetch(`/api/youtube/state/${urlHash}`)
|
||
.then(response => response.json())
|
||
.then(fullState => {
|
||
if (fullState.discovery_results) {
|
||
state.discoveryResults = fullState.discovery_results;
|
||
console.log(`✅ Loaded ${state.discoveryResults.length} discovery results`);
|
||
|
||
// Now open the modal with the loaded data
|
||
const playlistName = `[YouTube] ${state.playlist.name}`;
|
||
const spotifyTracks = state.discoveryResults
|
||
.filter(result => result.spotify_data)
|
||
.map(result => result.spotify_data);
|
||
openDownloadMissingModalForYouTube(spotifyPlaylistId, playlistName, spotifyTracks);
|
||
} else {
|
||
console.error('❌ No discovery results found for downloads');
|
||
showToast('Unable to open download modal - no discovery data', 'error');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('❌ Error loading discovery results:', error);
|
||
showToast('Error loading playlist data', 'error');
|
||
});
|
||
} else {
|
||
// Use the YouTube-specific function to maintain proper state linking
|
||
const playlistName = `[YouTube] ${state.playlist.name}`;
|
||
const spotifyTracks = state.discoveryResults
|
||
.filter(result => result.spotify_data)
|
||
.map(result => result.spotify_data);
|
||
openDownloadMissingModalForYouTube(spotifyPlaylistId, playlistName, spotifyTracks);
|
||
}
|
||
} else {
|
||
console.error('❌ No converted Spotify playlist ID found for downloads');
|
||
showToast('Unable to open download modal - missing playlist data', 'error');
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
function updateYouTubeCardProgress(urlHash, progress) {
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.cardElement) return;
|
||
|
||
const card = state.cardElement;
|
||
const progressElement = card.querySelector('.playlist-card-progress');
|
||
|
||
const total = progress.spotify_total || 0;
|
||
const matches = progress.spotify_matches || 0;
|
||
const failed = total - matches;
|
||
const percentage = total > 0 ? Math.round((matches / total) * 100) : 0;
|
||
|
||
progressElement.textContent = `♪ ${total} / ✓ ${matches} / ✗ ${failed} / ${percentage}%`;
|
||
|
||
console.log('🃏 Updated YouTube card progress:', urlHash, `${matches}/${total} (${percentage}%)`);
|
||
}
|
||
|
||
function removeYouTubeCard(url) {
|
||
const state = Object.values(youtubePlaylistStates).find(s => s.url === url);
|
||
if (state && state.cardElement) {
|
||
state.cardElement.remove();
|
||
|
||
// Remove from state
|
||
if (state.urlHash) {
|
||
delete youtubePlaylistStates[state.urlHash];
|
||
} else if (state.tempHash) {
|
||
delete youtubePlaylistStates[state.tempHash];
|
||
}
|
||
}
|
||
|
||
// Show placeholder if no cards left
|
||
const container = document.getElementById('youtube-playlist-container');
|
||
const cards = container.querySelectorAll('.youtube-playlist-card');
|
||
const placeholder = container.querySelector('.playlist-placeholder');
|
||
|
||
if (cards.length === 0 && placeholder) {
|
||
placeholder.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
async function startYouTubeDiscovery(urlHash) {
|
||
try {
|
||
console.log('🔍 Starting YouTube Spotify discovery for:', urlHash);
|
||
|
||
const response = await fetch(`/api/youtube/discovery/start/${urlHash}`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.error) {
|
||
showToast(`Error starting discovery: ${result.error}`, 'error');
|
||
return;
|
||
}
|
||
|
||
// Start polling for progress
|
||
startYouTubeDiscoveryPolling(urlHash);
|
||
|
||
// Open discovery modal
|
||
openYouTubeDiscoveryModal(urlHash);
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error starting YouTube discovery:', error);
|
||
showToast(`Error starting discovery: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function startYouTubeDiscoveryPolling(urlHash) {
|
||
// Stop any existing polling
|
||
if (activeYouTubePollers[urlHash]) {
|
||
clearInterval(activeYouTubePollers[urlHash]);
|
||
}
|
||
|
||
const pollInterval = setInterval(async () => {
|
||
try {
|
||
const response = await fetch(`/api/youtube/discovery/status/${urlHash}`);
|
||
const status = await response.json();
|
||
|
||
if (status.error) {
|
||
console.error('❌ Error polling YouTube discovery status:', status.error);
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
return;
|
||
}
|
||
|
||
// Update card progress
|
||
updateYouTubeCardProgress(urlHash, status);
|
||
|
||
// Store discovery results and progress in state
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (state) {
|
||
state.discoveryResults = status.results || [];
|
||
state.discoveryProgress = status.progress || 0;
|
||
state.spotifyMatches = status.spotify_matches || 0;
|
||
}
|
||
|
||
// Update modal if open
|
||
updateYouTubeDiscoveryModal(urlHash, status);
|
||
|
||
// Check if complete
|
||
if (status.complete) {
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
|
||
// Update card phase to discovered
|
||
updateYouTubeCardPhase(urlHash, 'discovered');
|
||
|
||
// Update modal buttons to show sync and download buttons
|
||
updateYouTubeModalButtons(urlHash, 'discovered');
|
||
|
||
console.log('✅ YouTube discovery complete:', urlHash);
|
||
showToast('YouTube discovery complete!', 'success');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error polling YouTube discovery:', error);
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
}
|
||
}, 1000);
|
||
|
||
activeYouTubePollers[urlHash] = pollInterval;
|
||
}
|
||
|
||
function stopYouTubeDiscoveryPolling(urlHash) {
|
||
if (activeYouTubePollers[urlHash]) {
|
||
clearInterval(activeYouTubePollers[urlHash]);
|
||
delete activeYouTubePollers[urlHash];
|
||
console.log('⏹ Stopped YouTube discovery polling for:', urlHash);
|
||
}
|
||
}
|
||
|
||
function openYouTubeDiscoveryModal(urlHash) {
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.playlist) {
|
||
console.error('❌ No YouTube playlist data found for hash:', urlHash);
|
||
return;
|
||
}
|
||
|
||
console.log('🎵 Opening YouTube discovery modal for:', state.playlist.name);
|
||
|
||
// Check if modal already exists
|
||
let modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
|
||
|
||
if (modal) {
|
||
// Modal exists, just show it
|
||
modal.classList.remove('hidden');
|
||
console.log('🔄 Showing existing modal with preserved state');
|
||
|
||
// Resume polling if discovery is in progress
|
||
if (state.phase === 'discovering' && !activeYouTubePollers[urlHash]) {
|
||
console.log('🔄 Resuming discovery polling...');
|
||
startYouTubeDiscoveryPolling(urlHash);
|
||
}
|
||
} else {
|
||
// Create new modal (support both YouTube and Tidal like sync.py)
|
||
const isTidal = state.is_tidal_playlist;
|
||
const modalTitle = isTidal ? '🎵 Tidal Playlist Discovery' : '🎵 YouTube Playlist Discovery';
|
||
const sourceLabel = isTidal ? 'Tidal' : 'YT';
|
||
|
||
const modalHtml = `
|
||
<div class="modal-overlay" id="youtube-discovery-modal-${urlHash}">
|
||
<div class="youtube-discovery-modal">
|
||
<div class="modal-header">
|
||
<h2>${modalTitle}</h2>
|
||
<div class="modal-subtitle">${state.playlist.name} (${state.playlist.tracks.length} tracks)</div>
|
||
<div class="modal-description">${getModalDescription(state.phase, isTidal)}</div>
|
||
<button class="modal-close-btn" onclick="closeYouTubeDiscoveryModal('${urlHash}')">✕</button>
|
||
</div>
|
||
|
||
<div class="modal-body">
|
||
<div class="progress-section">
|
||
<div class="progress-label">🔍 Spotify Discovery Progress</div>
|
||
<div class="progress-bar-container">
|
||
<div class="progress-bar-fill" id="youtube-discovery-progress-${urlHash}" style="width: 0%;"></div>
|
||
</div>
|
||
<div class="progress-text" id="youtube-discovery-progress-text-${urlHash}">${getInitialProgressText(state.phase, isTidal)}</div>
|
||
</div>
|
||
|
||
<div class="discovery-table-container">
|
||
<table class="discovery-table">
|
||
<thead>
|
||
<tr>
|
||
<th>${sourceLabel} Track</th>
|
||
<th>${sourceLabel} Artist</th>
|
||
<th>Status</th>
|
||
<th>Spotify Track</th>
|
||
<th>Spotify Artist</th>
|
||
<th>Album</th>
|
||
${isTidal ? '' : '<th>Duration</th>'}
|
||
</tr>
|
||
</thead>
|
||
<tbody id="youtube-discovery-table-${urlHash}">
|
||
${generateTableRowsFromState(state, urlHash)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<div class="modal-footer-left">
|
||
${getModalActionButtons(urlHash, state.phase, state)}
|
||
</div>
|
||
<div class="modal-footer-right">
|
||
<button class="modal-btn modal-btn-secondary" onclick="closeYouTubeDiscoveryModal('${urlHash}')">🏠 Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Add modal to DOM
|
||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||
modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
|
||
|
||
// Store modal reference
|
||
state.modalElement = modal;
|
||
|
||
// Set initial progress if we have discovery results
|
||
if (state.discoveryResults && state.discoveryResults.length > 0) {
|
||
const progressData = {
|
||
progress: state.discoveryProgress || 0,
|
||
spotify_matches: state.spotifyMatches || 0,
|
||
spotify_total: state.playlist.tracks.length,
|
||
results: state.discoveryResults
|
||
};
|
||
updateYouTubeDiscoveryModal(urlHash, progressData);
|
||
}
|
||
|
||
console.log('✨ Created new modal with current state');
|
||
}
|
||
}
|
||
|
||
function getModalActionButtons(urlHash, phase, state = null) {
|
||
// Get state if not provided
|
||
if (!state) {
|
||
state = youtubePlaylistStates[urlHash];
|
||
}
|
||
|
||
const isTidal = state && state.is_tidal_playlist;
|
||
|
||
// Validate data availability for buttons
|
||
const hasDiscoveryResults = state && state.discoveryResults && state.discoveryResults.length > 0;
|
||
const hasSpotifyMatches = state && state.spotifyMatches > 0;
|
||
const hasConvertedPlaylistId = state && state.convertedSpotifyPlaylistId;
|
||
|
||
switch (phase) {
|
||
case 'discovered':
|
||
// Only show buttons if we actually have discovery data
|
||
if (!hasDiscoveryResults) {
|
||
return `<div class="modal-info">⚠️ No discovery results available. Try starting discovery again.</div>`;
|
||
}
|
||
|
||
let buttons = '';
|
||
|
||
// Only show sync button if there are Spotify matches
|
||
if (hasSpotifyMatches) {
|
||
if (isTidal) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startTidalPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubePlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
}
|
||
}
|
||
|
||
// Only show download button if we have matches or a converted playlist ID
|
||
if (hasSpotifyMatches || hasConvertedPlaylistId) {
|
||
if (isTidal) {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startTidalDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else {
|
||
buttons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
}
|
||
}
|
||
|
||
if (!buttons) {
|
||
buttons = `<div class="modal-info">ℹ️ No Spotify matches found. Discovery complete but no tracks could be matched.</div>`;
|
||
}
|
||
|
||
return buttons;
|
||
|
||
case 'syncing':
|
||
if (isTidal) {
|
||
return `
|
||
<button class="modal-btn modal-btn-danger" onclick="cancelTidalSync('${urlHash}')">❌ Cancel Sync</button>
|
||
<div class="playlist-modal-sync-status" id="tidal-sync-status-${urlHash}" style="display: flex;">
|
||
<span class="sync-stat total-tracks">♪ <span id="tidal-total-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ <span id="tidal-matched-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ <span id="tidal-failed-${urlHash}">0</span></span>
|
||
<span class="sync-stat percentage">(<span id="tidal-percentage-${urlHash}">0</span>%)</span>
|
||
</div>
|
||
`;
|
||
} else {
|
||
return `
|
||
<button class="modal-btn modal-btn-danger" onclick="cancelYouTubeSync('${urlHash}')">❌ Cancel Sync</button>
|
||
<div class="playlist-modal-sync-status" id="youtube-sync-status-${urlHash}" style="display: flex;">
|
||
<span class="sync-stat total-tracks">♪ <span id="youtube-total-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ <span id="youtube-matched-${urlHash}">0</span></span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ <span id="youtube-failed-${urlHash}">0</span></span>
|
||
<span class="sync-stat percentage">(<span id="youtube-percentage-${urlHash}">0</span>%)</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
case 'sync_complete':
|
||
let syncCompleteButtons = '';
|
||
|
||
// Only show sync button if there are Spotify matches
|
||
if (hasSpotifyMatches) {
|
||
if (isTidal) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startTidalPlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
} else {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubePlaylistSync('${urlHash}')">🔄 Sync This Playlist</button>`;
|
||
}
|
||
}
|
||
|
||
// Only show download button if we have matches or a converted playlist ID
|
||
if (hasSpotifyMatches || hasConvertedPlaylistId) {
|
||
if (isTidal) {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startTidalDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
} else {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startYouTubeDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
|
||
}
|
||
}
|
||
|
||
if (isTidal) {
|
||
// Tidal doesn't have a reset function yet, but could be added
|
||
// syncCompleteButtons += `<button class="modal-btn modal-btn-secondary" onclick="resetTidalPlaylist('${urlHash}')">🔄 Reset</button>`;
|
||
} else {
|
||
syncCompleteButtons += `<button class="modal-btn modal-btn-secondary" onclick="resetYouTubePlaylist('${urlHash}')">🔄 Reset</button>`;
|
||
}
|
||
|
||
return syncCompleteButtons;
|
||
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function getModalDescription(phase, isTidal = false) {
|
||
const source = isTidal ? 'Tidal' : 'YouTube';
|
||
switch (phase) {
|
||
case 'fresh':
|
||
return `Ready to discover clean Spotify metadata for ${source} tracks...`;
|
||
case 'discovering':
|
||
return `Discovering clean Spotify metadata for ${source} tracks...`;
|
||
case 'discovered':
|
||
return 'Discovery complete! View the results below.';
|
||
default:
|
||
return `Discovering clean Spotify metadata for ${source} tracks...`;
|
||
}
|
||
}
|
||
|
||
function getInitialProgressText(phase, isTidal = false) {
|
||
switch (phase) {
|
||
case 'fresh':
|
||
return 'Click Start Discovery to begin...';
|
||
case 'discovering':
|
||
return 'Starting discovery...';
|
||
case 'discovered':
|
||
return 'Discovery completed!';
|
||
default:
|
||
return 'Starting discovery...';
|
||
}
|
||
}
|
||
|
||
function generateTableRowsFromState(state, urlHash) {
|
||
const isTidal = state.is_tidal_playlist;
|
||
|
||
if (state.discoveryResults && state.discoveryResults.length > 0) {
|
||
// Generate rows from existing discovery results
|
||
return state.discoveryResults.map((result, index) => `
|
||
<tr id="discovery-row-${urlHash}-${result.index}">
|
||
<td class="yt-track">${result.yt_track}</td>
|
||
<td class="yt-artist">${result.yt_artist}</td>
|
||
<td class="discovery-status ${result.status_class}">${result.status}</td>
|
||
<td class="spotify-track">${result.spotify_track || '-'}</td>
|
||
<td class="spotify-artist">${result.spotify_artist || '-'}</td>
|
||
<td class="spotify-album">${result.spotify_album || '-'}</td>
|
||
${isTidal ? '' : `<td class="duration">${result.duration}</td>`}
|
||
</tr>
|
||
`).join('');
|
||
} else {
|
||
// Generate initial rows from playlist tracks
|
||
return generateInitialTableRows(state.playlist.tracks, isTidal, urlHash);
|
||
}
|
||
}
|
||
|
||
function generateInitialTableRows(tracks, isTidal = false, urlHash = '') {
|
||
return tracks.map((track, index) => `
|
||
<tr id="discovery-row-${urlHash}-${index}">
|
||
<td class="yt-track">${track.name}</td>
|
||
<td class="yt-artist">${track.artists ? (Array.isArray(track.artists) ? track.artists.join(', ') : track.artists) : 'Unknown Artist'}</td>
|
||
<td class="discovery-status">🔍 Pending...</td>
|
||
<td class="spotify-track">-</td>
|
||
<td class="spotify-artist">-</td>
|
||
<td class="spotify-album">-</td>
|
||
${isTidal ? '' : `<td class="duration">${formatDuration(track.duration_ms)}</td>`}
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function formatDuration(durationMs) {
|
||
if (!durationMs) return '0:00';
|
||
const minutes = Math.floor(durationMs / 60000);
|
||
const seconds = Math.floor((durationMs % 60000) / 1000);
|
||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
function updateYouTubeDiscoveryModal(urlHash, status) {
|
||
const progressBar = document.getElementById(`youtube-discovery-progress-${urlHash}`);
|
||
const progressText = document.getElementById(`youtube-discovery-progress-text-${urlHash}`);
|
||
const tableBody = document.getElementById(`youtube-discovery-table-${urlHash}`);
|
||
|
||
if (!progressBar || !progressText || !tableBody) {
|
||
console.warn(`⚠️ Missing modal elements for ${urlHash}:`, {
|
||
progressBar: !!progressBar,
|
||
progressText: !!progressText,
|
||
tableBody: !!tableBody
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Update progress bar
|
||
progressBar.style.width = `${status.progress}%`;
|
||
progressText.textContent = `${status.spotify_matches} / ${status.spotify_total} tracks matched (${status.progress}%)`;
|
||
|
||
|
||
// Update table rows
|
||
status.results.forEach(result => {
|
||
const row = document.getElementById(`discovery-row-${urlHash}-${result.index}`);
|
||
if (!row) return;
|
||
|
||
const statusCell = row.querySelector('.discovery-status');
|
||
const spotifyTrackCell = row.querySelector('.spotify-track');
|
||
const spotifyArtistCell = row.querySelector('.spotify-artist');
|
||
const spotifyAlbumCell = row.querySelector('.spotify-album');
|
||
|
||
statusCell.textContent = result.status;
|
||
statusCell.className = `discovery-status ${result.status_class}`;
|
||
|
||
spotifyTrackCell.textContent = result.spotify_track || '-';
|
||
spotifyArtistCell.textContent = result.spotify_artist || '-';
|
||
spotifyAlbumCell.textContent = result.spotify_album || '-';
|
||
});
|
||
|
||
// Update action buttons if discovery is complete (progress = 100%)
|
||
if (status.progress >= 100) {
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (state && state.phase === 'discovered') {
|
||
const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`);
|
||
if (actionButtonsContainer) {
|
||
actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state);
|
||
console.log(`✨ Updated action buttons for completed discovery: ${urlHash}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function refreshYouTubeDiscoveryModalTable(urlHash) {
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.modalElement) {
|
||
console.warn(`⚠️ Cannot refresh modal table: no state or modal for ${urlHash}`);
|
||
return;
|
||
}
|
||
|
||
console.log(`🔄 Refreshing modal table with ${state.discoveryResults?.length || 0} discovery results`);
|
||
|
||
// Update the table body with new discovery results
|
||
const tableBody = state.modalElement.querySelector(`#youtube-discovery-table-${urlHash}`);
|
||
if (tableBody) {
|
||
tableBody.innerHTML = generateTableRowsFromState(state, urlHash);
|
||
console.log(`✅ Modal table refreshed with discovery data`);
|
||
} else {
|
||
console.warn(`⚠️ Could not find table body for modal ${urlHash}`);
|
||
}
|
||
|
||
// Update the progress bar and footer buttons too
|
||
if (state.discoveryResults && state.discoveryResults.length > 0) {
|
||
const progressData = {
|
||
progress: state.discoveryProgress || 100,
|
||
spotify_matches: state.spotifyMatches || 0,
|
||
spotify_total: state.playlist.tracks.length,
|
||
results: state.discoveryResults
|
||
};
|
||
updateYouTubeDiscoveryModal(urlHash, progressData);
|
||
}
|
||
}
|
||
|
||
function closeYouTubeDiscoveryModal(urlHash) {
|
||
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
|
||
if (modal) {
|
||
// Hide modal instead of removing it to preserve state
|
||
modal.classList.add('hidden');
|
||
console.log('🚪 Hidden YouTube discovery modal (preserving state):', urlHash);
|
||
}
|
||
|
||
// Keep modal reference and all state intact
|
||
// Discovery polling continues in background if active
|
||
}
|
||
|
||
// ===============================
|
||
// YOUTUBE SYNC FUNCTIONALITY
|
||
// ===============================
|
||
|
||
async function startYouTubePlaylistSync(urlHash) {
|
||
try {
|
||
console.log('🔄 Starting YouTube playlist sync:', urlHash);
|
||
|
||
const response = await fetch(`/api/youtube/sync/start/${urlHash}`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.error) {
|
||
showToast(`Error starting sync: ${result.error}`, 'error');
|
||
return;
|
||
}
|
||
|
||
// Update card and modal to syncing phase
|
||
updateYouTubeCardPhase(urlHash, 'syncing');
|
||
|
||
// Update modal buttons if modal is open
|
||
updateYouTubeModalButtons(urlHash, 'syncing');
|
||
|
||
// Start sync polling
|
||
startYouTubeSyncPolling(urlHash);
|
||
|
||
showToast('YouTube playlist sync started!', 'success');
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error starting YouTube sync:', error);
|
||
showToast(`Error starting sync: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function startYouTubeSyncPolling(urlHash) {
|
||
// Stop any existing polling
|
||
if (activeYouTubePollers[urlHash]) {
|
||
clearInterval(activeYouTubePollers[urlHash]);
|
||
}
|
||
|
||
const pollInterval = setInterval(async () => {
|
||
try {
|
||
const response = await fetch(`/api/youtube/sync/status/${urlHash}`);
|
||
const status = await response.json();
|
||
|
||
if (status.error) {
|
||
console.error('❌ Error polling YouTube sync status:', status.error);
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
return;
|
||
}
|
||
|
||
// Update card progress with sync stats
|
||
updateYouTubeCardSyncProgress(urlHash, status.progress);
|
||
|
||
// Update modal sync display if open
|
||
updateYouTubeModalSyncProgress(urlHash, status.progress);
|
||
|
||
// Check if complete
|
||
if (status.complete) {
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
|
||
// Update card phase to sync complete
|
||
updateYouTubeCardPhase(urlHash, 'sync_complete');
|
||
|
||
// Update modal buttons
|
||
updateYouTubeModalButtons(urlHash, 'sync_complete');
|
||
|
||
console.log('✅ YouTube sync complete:', urlHash);
|
||
showToast('YouTube playlist sync complete!', 'success');
|
||
} else if (status.sync_status === 'error') {
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
|
||
// Revert to discovered phase on error
|
||
updateYouTubeCardPhase(urlHash, 'discovered');
|
||
updateYouTubeModalButtons(urlHash, 'discovered');
|
||
|
||
showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error polling YouTube sync:', error);
|
||
clearInterval(pollInterval);
|
||
delete activeYouTubePollers[urlHash];
|
||
}
|
||
}, 1000);
|
||
|
||
activeYouTubePollers[urlHash] = pollInterval;
|
||
}
|
||
|
||
async function cancelYouTubeSync(urlHash) {
|
||
try {
|
||
console.log('❌ Cancelling YouTube sync:', urlHash);
|
||
|
||
const response = await fetch(`/api/youtube/sync/cancel/${urlHash}`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.error) {
|
||
showToast(`Error cancelling sync: ${result.error}`, 'error');
|
||
return;
|
||
}
|
||
|
||
// Stop polling
|
||
if (activeYouTubePollers[urlHash]) {
|
||
clearInterval(activeYouTubePollers[urlHash]);
|
||
delete activeYouTubePollers[urlHash];
|
||
}
|
||
|
||
// Revert to discovered phase
|
||
updateYouTubeCardPhase(urlHash, 'discovered');
|
||
updateYouTubeModalButtons(urlHash, 'discovered');
|
||
|
||
showToast('YouTube sync cancelled', 'info');
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error cancelling YouTube sync:', error);
|
||
showToast(`Error cancelling sync: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function updateYouTubeCardSyncProgress(urlHash, progress) {
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.cardElement || !progress) return;
|
||
|
||
const card = state.cardElement;
|
||
const progressElement = card.querySelector('.playlist-card-progress');
|
||
|
||
// Build clean status counter HTML exactly like Spotify cards
|
||
let statusCounterHTML = '';
|
||
if (progress && progress.total_tracks > 0) {
|
||
const matched = progress.matched_tracks || 0;
|
||
const failed = progress.failed_tracks || 0;
|
||
const total = progress.total_tracks || 0;
|
||
const processed = matched + failed;
|
||
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||
|
||
statusCounterHTML = `
|
||
<div class="playlist-card-sync-status">
|
||
<span class="sync-stat total-tracks">♪ ${total}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat matched-tracks">✓ ${matched}</span>
|
||
<span class="sync-separator">/</span>
|
||
<span class="sync-stat failed-tracks">✗ ${failed}</span>
|
||
<span class="sync-stat percentage">(${percentage}%)</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Only update if we have valid sync progress, otherwise preserve existing discovery results
|
||
if (statusCounterHTML) {
|
||
progressElement.innerHTML = statusCounterHTML;
|
||
}
|
||
|
||
console.log(`🔄 Updated YouTube sync progress: ♪ ${progress?.total_tracks || 0} / ✓ ${progress?.matched_tracks || 0} / ✗ ${progress?.failed_tracks || 0}`);
|
||
}
|
||
|
||
function updateYouTubeModalSyncProgress(urlHash, progress) {
|
||
const statusDisplay = document.getElementById(`youtube-sync-status-${urlHash}`);
|
||
if (!statusDisplay || !progress) return;
|
||
|
||
console.log(`📊 Updating YouTube modal sync progress for ${urlHash}:`, progress);
|
||
|
||
// Update individual counters exactly like Spotify sync
|
||
const totalEl = document.getElementById(`youtube-total-${urlHash}`);
|
||
const matchedEl = document.getElementById(`youtube-matched-${urlHash}`);
|
||
const failedEl = document.getElementById(`youtube-failed-${urlHash}`);
|
||
const percentageEl = document.getElementById(`youtube-percentage-${urlHash}`);
|
||
|
||
const total = progress.total_tracks || 0;
|
||
const matched = progress.matched_tracks || 0;
|
||
const failed = progress.failed_tracks || 0;
|
||
|
||
if (totalEl) totalEl.textContent = total;
|
||
if (matchedEl) matchedEl.textContent = matched;
|
||
if (failedEl) failedEl.textContent = failed;
|
||
|
||
// Calculate percentage like Spotify sync
|
||
if (total > 0) {
|
||
const processed = matched + failed;
|
||
const percentage = Math.round((processed / total) * 100);
|
||
if (percentageEl) percentageEl.textContent = percentage;
|
||
}
|
||
|
||
console.log(`📊 YouTube modal updated: ♪ ${total} / ✓ ${matched} / ✗ ${failed} (${Math.round((matched + failed) / total * 100)}%)`);
|
||
}
|
||
|
||
function updateYouTubeModalButtons(urlHash, phase) {
|
||
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
|
||
if (!modal) return;
|
||
|
||
const footerLeft = modal.querySelector('.modal-footer-left');
|
||
if (footerLeft) {
|
||
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
|
||
}
|
||
}
|
||
|
||
// ===============================
|
||
// YOUTUBE DOWNLOAD MISSING TRACKS
|
||
// ===============================
|
||
|
||
async function startYouTubeDownloadMissing(urlHash) {
|
||
try {
|
||
console.log('🔍 Starting download missing tracks for YouTube playlist:', urlHash);
|
||
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state || !state.discoveryResults) {
|
||
showToast('No discovery results available for download', 'error');
|
||
return;
|
||
}
|
||
|
||
// Convert YouTube results to a format compatible with the download modal
|
||
const spotifyTracks = state.discoveryResults
|
||
.filter(result => result.spotify_data)
|
||
.map(result => result.spotify_data);
|
||
|
||
if (spotifyTracks.length === 0) {
|
||
showToast('No Spotify matches found for download', 'error');
|
||
return;
|
||
}
|
||
|
||
// Create a virtual playlist for the download system
|
||
const virtualPlaylistId = `youtube_${urlHash}`;
|
||
const playlistName = `[YouTube] ${state.playlist.name}`;
|
||
|
||
// Store reference for card navigation
|
||
state.convertedSpotifyPlaylistId = virtualPlaylistId;
|
||
|
||
// Open download missing tracks modal for YouTube playlist
|
||
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
|
||
|
||
// Phase will change to 'downloading' when user clicks "Begin Analysis" button
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error starting download missing tracks:', error);
|
||
showToast(`Error starting downloads: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function resetYouTubePlaylist(urlHash) {
|
||
const state = youtubePlaylistStates[urlHash];
|
||
if (!state) return;
|
||
|
||
try {
|
||
console.log(`🔄 Resetting YouTube playlist to fresh state: ${state.playlist.name}`);
|
||
|
||
// Call backend reset endpoint
|
||
const response = await fetch(`/api/youtube/reset/${urlHash}`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || 'Failed to reset playlist');
|
||
}
|
||
|
||
// Stop any active polling
|
||
if (activeYouTubePollers[urlHash]) {
|
||
clearInterval(activeYouTubePollers[urlHash]);
|
||
delete activeYouTubePollers[urlHash];
|
||
}
|
||
|
||
// Update client state to match backend reset
|
||
state.phase = 'fresh';
|
||
state.discoveryResults = [];
|
||
state.discoveryProgress = 0;
|
||
state.spotifyMatches = 0;
|
||
state.syncPlaylistId = null;
|
||
state.syncProgress = {};
|
||
state.convertedSpotifyPlaylistId = null;
|
||
|
||
// Update card to reflect fresh state
|
||
updateYouTubeCardPhase(urlHash, 'fresh');
|
||
updateYouTubeCardProgress(urlHash, {
|
||
discovery_progress: 0,
|
||
spotify_matches: 0,
|
||
spotify_total: state.playlist.tracks.length
|
||
});
|
||
|
||
// Close modal
|
||
closeYouTubeDiscoveryModal(urlHash);
|
||
|
||
showToast(`Reset "${state.playlist.name}" to fresh state`, 'success');
|
||
console.log(`✅ Successfully reset YouTube playlist: ${state.playlist.name}`);
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error resetting YouTube playlist:`, error);
|
||
showToast(`Error resetting playlist: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// ARTISTS PAGE FUNCTIONALITY - ELEGANT SEARCH & DISCOVERY
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Initialize the artists page when navigated to (only runs once)
|
||
*/
|
||
function initializeArtistsPage() {
|
||
console.log('🎵 Initializing Artists Page (first time)');
|
||
|
||
// Get DOM elements
|
||
const searchInput = document.getElementById('artists-search-input');
|
||
const headerSearchInput = document.getElementById('artists-header-search-input');
|
||
const searchStatus = document.getElementById('artists-search-status');
|
||
const backButton = document.getElementById('artists-back-button');
|
||
const detailBackButton = document.getElementById('artist-detail-back-button');
|
||
|
||
// Set up event listeners (only need to do this once)
|
||
if (searchInput) {
|
||
searchInput.addEventListener('input', handleArtistsSearchInput);
|
||
searchInput.addEventListener('keypress', handleArtistsSearchKeypress);
|
||
}
|
||
|
||
if (headerSearchInput) {
|
||
headerSearchInput.addEventListener('input', handleArtistsHeaderSearchInput);
|
||
headerSearchInput.addEventListener('keypress', handleArtistsSearchKeypress);
|
||
}
|
||
|
||
if (backButton) {
|
||
backButton.addEventListener('click', () => showArtistsSearchState());
|
||
}
|
||
|
||
if (detailBackButton) {
|
||
detailBackButton.addEventListener('click', () => showArtistsResultsState());
|
||
}
|
||
|
||
// Initialize tabs (only need to do this once)
|
||
initializeArtistTabs();
|
||
|
||
// Mark as initialized
|
||
artistsPageState.isInitialized = true;
|
||
|
||
// Restore previous state instead of always resetting to search
|
||
restoreArtistsPageState();
|
||
console.log('✅ Artists Page initialized successfully (ready for navigation)');
|
||
}
|
||
|
||
/**
|
||
* Restore the artists page to its previous state
|
||
*/
|
||
function restoreArtistsPageState() {
|
||
console.log(`🔄 Restoring artists page state: ${artistsPageState.currentView}`);
|
||
|
||
switch (artistsPageState.currentView) {
|
||
case 'results':
|
||
// Restore search results state
|
||
if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) {
|
||
console.log(`📦 Restoring search results for: "${artistsPageState.searchQuery}"`);
|
||
|
||
// Restore search input values
|
||
const searchInput = document.getElementById('artists-search-input');
|
||
const headerSearchInput = document.getElementById('artists-header-search-input');
|
||
|
||
if (searchInput) searchInput.value = artistsPageState.searchQuery;
|
||
if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery;
|
||
|
||
// Display the cached results
|
||
displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults);
|
||
} else {
|
||
// No valid results state, fall back to search
|
||
showArtistsSearchState();
|
||
}
|
||
break;
|
||
|
||
case 'detail':
|
||
// Restore artist detail state
|
||
if (artistsPageState.selectedArtist && artistsPageState.artistDiscography) {
|
||
console.log(`🎤 Restoring artist detail for: ${artistsPageState.selectedArtist.name}`);
|
||
|
||
// First restore search results if they exist
|
||
if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) {
|
||
const searchInput = document.getElementById('artists-search-input');
|
||
const headerSearchInput = document.getElementById('artists-header-search-input');
|
||
|
||
if (searchInput) searchInput.value = artistsPageState.searchQuery;
|
||
if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery;
|
||
}
|
||
|
||
// Show artist detail state
|
||
showArtistDetailState();
|
||
|
||
// Update artist info in header
|
||
updateArtistDetailHeader(artistsPageState.selectedArtist);
|
||
|
||
// Display cached discography
|
||
if (artistsPageState.artistDiscography.albums || artistsPageState.artistDiscography.singles) {
|
||
displayArtistDiscography(artistsPageState.artistDiscography);
|
||
// Restore cached completion data instead of re-scanning
|
||
restoreCachedCompletionData(artistsPageState.selectedArtist.id);
|
||
}
|
||
} else {
|
||
// No valid detail state, fall back to search or results
|
||
if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) {
|
||
displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults);
|
||
} else {
|
||
showArtistsSearchState();
|
||
}
|
||
}
|
||
break;
|
||
|
||
default:
|
||
case 'search':
|
||
// Show search state (but preserve any existing search query)
|
||
if (artistsPageState.searchQuery) {
|
||
const searchInput = document.getElementById('artists-search-input');
|
||
if (searchInput) searchInput.value = artistsPageState.searchQuery;
|
||
}
|
||
showArtistsSearchState();
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle search input with debouncing
|
||
*/
|
||
function handleArtistsSearchInput(event) {
|
||
const query = event.target.value.trim();
|
||
updateArtistsSearchStatus('searching');
|
||
|
||
// Clear existing timeout
|
||
if (artistsSearchTimeout) {
|
||
clearTimeout(artistsSearchTimeout);
|
||
}
|
||
|
||
// Cancel any active search
|
||
if (artistsSearchController) {
|
||
artistsSearchController.abort();
|
||
}
|
||
|
||
if (query === '') {
|
||
updateArtistsSearchStatus('default');
|
||
return;
|
||
}
|
||
|
||
// Set up new debounced search
|
||
artistsSearchTimeout = setTimeout(() => {
|
||
performArtistsSearch(query);
|
||
}, 1000); // 1 second debounce
|
||
}
|
||
|
||
/**
|
||
* Handle header search input (already in results state)
|
||
*/
|
||
function handleArtistsHeaderSearchInput(event) {
|
||
const query = event.target.value.trim();
|
||
|
||
// Update main search input to match
|
||
const mainInput = document.getElementById('artists-search-input');
|
||
if (mainInput) {
|
||
mainInput.value = query;
|
||
}
|
||
|
||
// Trigger search with same debouncing logic
|
||
handleArtistsSearchInput(event);
|
||
}
|
||
|
||
/**
|
||
* Handle Enter key press in search inputs
|
||
*/
|
||
function handleArtistsSearchKeypress(event) {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
const query = event.target.value.trim();
|
||
|
||
if (query && query !== artistsPageState.searchQuery) {
|
||
// Clear timeout and search immediately
|
||
if (artistsSearchTimeout) {
|
||
clearTimeout(artistsSearchTimeout);
|
||
}
|
||
performArtistsSearch(query);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Perform artist search with API call
|
||
*/
|
||
async function performArtistsSearch(query) {
|
||
console.log(`🔍 Searching for artists: "${query}"`);
|
||
|
||
// Check cache first
|
||
if (artistsPageState.cache.searches[query]) {
|
||
console.log('📦 Using cached search results');
|
||
displayArtistsResults(query, artistsPageState.cache.searches[query]);
|
||
return;
|
||
}
|
||
|
||
// Update status
|
||
updateArtistsSearchStatus('searching');
|
||
|
||
// Show loading cards immediately if we're in results view
|
||
if (artistsPageState.currentView === 'results') {
|
||
showSearchLoadingCards();
|
||
}
|
||
|
||
try {
|
||
// Set up abort controller
|
||
artistsSearchController = new AbortController();
|
||
|
||
const response = await fetch('/api/match/search', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
query: query,
|
||
context: 'artist'
|
||
}),
|
||
signal: artistsSearchController.signal
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Search failed: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.log(`✅ Found ${data.results?.length || 0} artists`);
|
||
|
||
// Transform the results to flatten the nested artist data
|
||
const transformedResults = (data.results || []).map(result => {
|
||
// Extract artist data from the nested structure
|
||
const artist = result.artist || result;
|
||
return {
|
||
id: artist.id,
|
||
name: artist.name,
|
||
image_url: artist.image_url,
|
||
genres: artist.genres,
|
||
popularity: artist.popularity,
|
||
confidence: result.confidence || 0
|
||
};
|
||
});
|
||
|
||
console.log('🔧 Transformed results:', transformedResults);
|
||
|
||
// Cache the transformed results
|
||
artistsPageState.cache.searches[query] = transformedResults;
|
||
|
||
// Display results
|
||
displayArtistsResults(query, transformedResults);
|
||
|
||
} catch (error) {
|
||
if (error.name !== 'AbortError') {
|
||
console.error('❌ Artist search failed:', error);
|
||
|
||
// Provide specific error messages based on the error type
|
||
let errorMessage = 'Search failed. Please try again.';
|
||
if (error.message.includes('401') || error.message.includes('authentication')) {
|
||
errorMessage = 'Spotify not authenticated. Please check your API settings.';
|
||
} else if (error.message.includes('network') || error.message.includes('fetch')) {
|
||
errorMessage = 'Network error. Please check your connection.';
|
||
} else if (error.message.includes('timeout')) {
|
||
errorMessage = 'Search timed out. Please try again.';
|
||
}
|
||
|
||
updateArtistsSearchStatus('error', errorMessage);
|
||
}
|
||
} finally {
|
||
artistsSearchController = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Display artist search results
|
||
*/
|
||
function displayArtistsResults(query, results) {
|
||
console.log(`📊 Displaying ${results.length} artist results`);
|
||
|
||
// Update state
|
||
artistsPageState.searchQuery = query;
|
||
artistsPageState.searchResults = results;
|
||
artistsPageState.currentView = 'results';
|
||
|
||
// Update header search input if different
|
||
const headerInput = document.getElementById('artists-header-search-input');
|
||
if (headerInput && headerInput.value !== query) {
|
||
headerInput.value = query;
|
||
}
|
||
|
||
// Show results state
|
||
showArtistsResultsState();
|
||
|
||
// Populate results
|
||
const container = document.getElementById('artists-cards-container');
|
||
if (!container) return;
|
||
|
||
if (results.length === 0) {
|
||
container.innerHTML = `
|
||
<div style="grid-column: 1 / -1; text-align: center; padding: 60px 20px; color: rgba(255, 255, 255, 0.6);">
|
||
<div style="font-size: 24px; margin-bottom: 12px;">🔍</div>
|
||
<div style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">No artists found</div>
|
||
<div style="font-size: 14px;">Try a different search term</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Create artist cards
|
||
container.innerHTML = results.map(result => createArtistCard(result)).join('');
|
||
|
||
// Add event listeners to cards
|
||
container.querySelectorAll('.artist-card').forEach((card, index) => {
|
||
card.addEventListener('click', () => selectArtist(results[index]));
|
||
|
||
// Extract colors from artist image for dynamic glow
|
||
const artist = results[index];
|
||
if (artist.image_url) {
|
||
extractImageColors(artist.image_url, (colors) => {
|
||
applyDynamicGlow(card, colors);
|
||
});
|
||
}
|
||
});
|
||
|
||
// Add mouse wheel horizontal scrolling
|
||
container.addEventListener('wheel', (event) => {
|
||
if (event.deltaY !== 0) {
|
||
event.preventDefault();
|
||
container.scrollLeft += event.deltaY;
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Create HTML for an artist card
|
||
*/
|
||
function createArtistCard(artist) {
|
||
const imageUrl = artist.image_url || '';
|
||
const genres = artist.genres && artist.genres.length > 0 ?
|
||
artist.genres.slice(0, 3).join(', ') : 'Various genres';
|
||
const popularity = artist.popularity || 0;
|
||
|
||
// Create a fallback gradient if no image is available
|
||
const backgroundStyle = imageUrl ?
|
||
`background-image: url('${imageUrl}');` :
|
||
`background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
|
||
|
||
// Format popularity as a percentage for better UX
|
||
const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown';
|
||
|
||
return `
|
||
<div class="artist-card" data-artist-id="${artist.id}">
|
||
<div class="artist-card-background" style="${backgroundStyle}"></div>
|
||
<div class="artist-card-overlay"></div>
|
||
<div class="artist-card-content">
|
||
<div class="artist-card-name">${escapeHtml(artist.name)}</div>
|
||
<div class="artist-card-genres">${escapeHtml(genres)}</div>
|
||
<div class="artist-card-popularity">
|
||
<span class="popularity-icon">🔥</span>
|
||
<span>${popularityText}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Select an artist and show their discography
|
||
*/
|
||
async function selectArtist(artist) {
|
||
console.log(`🎤 Selected artist: ${artist.name}`);
|
||
|
||
// Update state
|
||
artistsPageState.selectedArtist = artist;
|
||
artistsPageState.currentView = 'detail';
|
||
|
||
// Show detail state
|
||
showArtistDetailState();
|
||
|
||
// Update artist info in header
|
||
updateArtistDetailHeader(artist);
|
||
|
||
// Load discography
|
||
await loadArtistDiscography(artist.id);
|
||
}
|
||
|
||
/**
|
||
* Load artist's discography from Spotify
|
||
*/
|
||
async function loadArtistDiscography(artistId) {
|
||
console.log(`💿 Loading discography for artist: ${artistId}`);
|
||
|
||
// Check cache first
|
||
if (artistsPageState.cache.discography[artistId]) {
|
||
console.log('📦 Using cached discography');
|
||
const cachedDiscography = artistsPageState.cache.discography[artistId];
|
||
displayArtistDiscography(cachedDiscography);
|
||
// Still check completion status for cached data
|
||
await checkDiscographyCompletion(artistId, cachedDiscography);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Show loading states
|
||
showDiscographyLoading();
|
||
|
||
// Call the real API endpoint
|
||
const response = await fetch(`/api/artist/${artistId}/discography`);
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
throw new Error('Spotify not authenticated. Please check your API settings.');
|
||
}
|
||
throw new Error(`Failed to load discography: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
const discography = {
|
||
albums: data.albums || [],
|
||
singles: data.singles || []
|
||
};
|
||
|
||
console.log(`✅ Loaded ${discography.albums.length} albums and ${discography.singles.length} singles`);
|
||
|
||
// Cache the results
|
||
artistsPageState.cache.discography[artistId] = discography;
|
||
artistsPageState.artistDiscography = discography;
|
||
|
||
// Display results
|
||
displayArtistDiscography(discography);
|
||
|
||
// Check completion status for all albums and singles
|
||
await checkDiscographyCompletion(artistId, discography);
|
||
|
||
} catch (error) {
|
||
console.error('❌ Failed to load discography:', error);
|
||
showDiscographyError(error.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Display artist's discography in tabs
|
||
*/
|
||
function displayArtistDiscography(discography) {
|
||
console.log(`📀 Displaying discography: ${discography.albums?.length || 0} albums, ${discography.singles?.length || 0} singles`);
|
||
|
||
// Populate albums
|
||
const albumsContainer = document.getElementById('album-cards-container');
|
||
if (albumsContainer) {
|
||
if (discography.albums?.length > 0) {
|
||
albumsContainer.innerHTML = discography.albums.map(album => createAlbumCard(album)).join('');
|
||
|
||
// Add dynamic glow effects and click handlers to album cards
|
||
albumsContainer.querySelectorAll('.album-card').forEach((card, index) => {
|
||
const album = discography.albums[index];
|
||
if (album.image_url) {
|
||
extractImageColors(album.image_url, (colors) => {
|
||
applyDynamicGlow(card, colors);
|
||
});
|
||
}
|
||
|
||
// Add click handler for download missing tracks modal
|
||
card.addEventListener('click', () => handleArtistAlbumClick(album, 'albums'));
|
||
card.style.cursor = 'pointer';
|
||
});
|
||
} else {
|
||
albumsContainer.innerHTML = `
|
||
<div style="grid-column: 1 / -1; text-align: center; padding: 40px 20px; color: rgba(255, 255, 255, 0.6);">
|
||
<div style="font-size: 18px; margin-bottom: 8px;">💿</div>
|
||
<div style="font-size: 14px;">No albums found</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// Populate singles
|
||
const singlesContainer = document.getElementById('singles-cards-container');
|
||
if (singlesContainer) {
|
||
if (discography.singles?.length > 0) {
|
||
singlesContainer.innerHTML = discography.singles.map(single => createAlbumCard(single)).join('');
|
||
|
||
// Add dynamic glow effects and click handlers to singles cards
|
||
singlesContainer.querySelectorAll('.album-card').forEach((card, index) => {
|
||
const single = discography.singles[index];
|
||
if (single.image_url) {
|
||
extractImageColors(single.image_url, (colors) => {
|
||
applyDynamicGlow(card, colors);
|
||
});
|
||
}
|
||
|
||
// Add click handler for download missing tracks modal
|
||
card.addEventListener('click', () => handleArtistAlbumClick(single, 'singles'));
|
||
card.style.cursor = 'pointer';
|
||
});
|
||
} else {
|
||
singlesContainer.innerHTML = `
|
||
<div style="grid-column: 1 / -1; text-align: center; padding: 40px 20px; color: rgba(255, 255, 255, 0.6);">
|
||
<div style="font-size: 18px; margin-bottom: 8px;">🎵</div>
|
||
<div style="font-size: 14px;">No singles or EPs found</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Restore cached completion data without re-scanning the database
|
||
*/
|
||
function restoreCachedCompletionData(artistId) {
|
||
console.log(`📦 Restoring cached completion data for artist: ${artistId}`);
|
||
|
||
const cachedData = artistsPageState.cache.completionData[artistId];
|
||
if (!cachedData) {
|
||
console.log('⚠️ No cached completion data found, skipping restoration');
|
||
return;
|
||
}
|
||
|
||
// Restore album completion overlays
|
||
if (cachedData.albums) {
|
||
cachedData.albums.forEach(albumCompletion => {
|
||
updateAlbumCompletionOverlay(albumCompletion, 'albums');
|
||
});
|
||
console.log(`✅ Restored ${cachedData.albums.length} album completion overlays`);
|
||
}
|
||
|
||
// Restore singles completion overlays
|
||
if (cachedData.singles) {
|
||
cachedData.singles.forEach(singleCompletion => {
|
||
updateAlbumCompletionOverlay(singleCompletion, 'singles');
|
||
});
|
||
console.log(`✅ Restored ${cachedData.singles.length} single completion overlays`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check completion status for entire discography with streaming updates
|
||
*/
|
||
async function checkDiscographyCompletion(artistId, discography) {
|
||
console.log(`🔍 Starting streaming completion check for artist: ${artistId}`);
|
||
|
||
try {
|
||
// Use fetch with streaming response (Server-Sent Events)
|
||
|
||
// Use fetch with streaming response
|
||
const response = await fetch(`/api/artist/${artistId}/completion-stream`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
discography: discography,
|
||
artist_name: artistsPageState.selectedArtist?.name || 'Unknown Artist',
|
||
test_mode: window.location.search.includes('test=true')
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to start completion check: ${response.status}`);
|
||
}
|
||
|
||
// Handle streaming response
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
const chunk = decoder.decode(value);
|
||
const lines = chunk.split('\n');
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('data: ')) {
|
||
try {
|
||
const data = JSON.parse(line.slice(6));
|
||
handleStreamingCompletionUpdate(data);
|
||
} catch (e) {
|
||
console.warn('Failed to parse streaming data:', line);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ Failed to check completion status:', error);
|
||
showCompletionError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle individual streaming completion updates
|
||
*/
|
||
function handleStreamingCompletionUpdate(data) {
|
||
console.log('🔄 Streaming update received:', data.type, data.name || data.artist_name);
|
||
|
||
switch (data.type) {
|
||
case 'start':
|
||
console.log(`🎤 Starting completion check for ${data.artist_name} (${data.total_items} items)`);
|
||
// Initialize cache for this artist if not exists
|
||
const artistId = artistsPageState.selectedArtist?.id;
|
||
if (artistId && !artistsPageState.cache.completionData[artistId]) {
|
||
artistsPageState.cache.completionData[artistId] = {
|
||
albums: [],
|
||
singles: []
|
||
};
|
||
}
|
||
break;
|
||
|
||
case 'album_completion':
|
||
updateAlbumCompletionOverlay(data, 'albums');
|
||
// Cache the completion data
|
||
cacheCompletionData(data, 'albums');
|
||
console.log(`📀 Updated album: ${data.name} (${data.status})`);
|
||
break;
|
||
|
||
case 'single_completion':
|
||
updateAlbumCompletionOverlay(data, 'singles');
|
||
// Cache the completion data
|
||
cacheCompletionData(data, 'singles');
|
||
console.log(`🎵 Updated single: ${data.name} (${data.status})`);
|
||
break;
|
||
|
||
case 'error':
|
||
console.error('❌ Error processing item:', data.name, data.error);
|
||
// Could show error for specific item
|
||
break;
|
||
|
||
case 'complete':
|
||
console.log(`✅ Completion check finished (${data.processed_count} items processed)`);
|
||
break;
|
||
|
||
default:
|
||
console.log('Unknown streaming update type:', data.type);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cache completion data for future restoration
|
||
*/
|
||
function cacheCompletionData(completionData, type) {
|
||
const artistId = artistsPageState.selectedArtist?.id;
|
||
if (!artistId) return;
|
||
|
||
// Ensure cache structure exists
|
||
if (!artistsPageState.cache.completionData[artistId]) {
|
||
artistsPageState.cache.completionData[artistId] = {
|
||
albums: [],
|
||
singles: []
|
||
};
|
||
}
|
||
|
||
// Add to appropriate cache array
|
||
if (type === 'albums') {
|
||
artistsPageState.cache.completionData[artistId].albums.push(completionData);
|
||
} else if (type === 'singles') {
|
||
artistsPageState.cache.completionData[artistId].singles.push(completionData);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update completion overlay for a specific album/single
|
||
*/
|
||
function updateAlbumCompletionOverlay(completionData, containerType) {
|
||
const containerId = containerType === 'albums' ? 'album-cards-container' : 'singles-cards-container';
|
||
const container = document.getElementById(containerId);
|
||
|
||
if (!container) {
|
||
console.warn(`Container ${containerId} not found`);
|
||
return;
|
||
}
|
||
|
||
// Find the album card by data-album-id
|
||
const albumCard = container.querySelector(`[data-album-id="${completionData.id}"]`);
|
||
|
||
if (!albumCard) {
|
||
console.warn(`Album card not found for ID: ${completionData.id}`);
|
||
return;
|
||
}
|
||
|
||
const overlay = albumCard.querySelector('.completion-overlay');
|
||
if (!overlay) {
|
||
console.warn(`Completion overlay not found for album: ${completionData.name}`);
|
||
return;
|
||
}
|
||
|
||
// Remove existing status classes
|
||
overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'error');
|
||
|
||
// Add new status class
|
||
overlay.classList.add(completionData.status);
|
||
|
||
// Update overlay text and content
|
||
const statusText = getCompletionStatusText(completionData);
|
||
const progressText = `${completionData.owned_tracks}/${completionData.expected_tracks}`;
|
||
|
||
overlay.innerHTML = `
|
||
<span class="completion-status">${statusText}</span>
|
||
<span class="completion-progress">${progressText}</span>
|
||
`;
|
||
|
||
// Add tooltip with more details
|
||
overlay.title = `${completionData.name}\n${statusText} (${completionData.completion_percentage}%)\nTracks: ${completionData.owned_tracks}/${completionData.expected_tracks}\nConfidence: ${completionData.confidence}`;
|
||
|
||
// Add brief flash animation to indicate update
|
||
overlay.style.animation = 'none';
|
||
overlay.offsetHeight; // Trigger reflow
|
||
overlay.style.animation = 'completionOverlayFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1)';
|
||
|
||
console.log(`📊 Updated overlay for "${completionData.name}": ${statusText} (${completionData.completion_percentage}%)`);
|
||
}
|
||
|
||
/**
|
||
* Get human-readable status text for completion overlay
|
||
*/
|
||
function getCompletionStatusText(completionData) {
|
||
switch (completionData.status) {
|
||
case 'completed':
|
||
return 'Complete';
|
||
case 'nearly_complete':
|
||
return 'Nearly Complete';
|
||
case 'partial':
|
||
return 'Partial';
|
||
case 'missing':
|
||
return 'Missing';
|
||
case 'error':
|
||
return 'Error';
|
||
default:
|
||
return 'Unknown';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show error state on all completion overlays
|
||
*/
|
||
function showCompletionError() {
|
||
const allOverlays = document.querySelectorAll('.completion-overlay.checking');
|
||
allOverlays.forEach(overlay => {
|
||
overlay.classList.remove('checking');
|
||
overlay.classList.add('error');
|
||
overlay.innerHTML = '<span class="completion-status">Error</span>';
|
||
overlay.title = 'Failed to check completion status';
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Create HTML for an album/single card
|
||
*/
|
||
function createAlbumCard(album) {
|
||
const imageUrl = album.image_url || '';
|
||
const year = album.release_date ? new Date(album.release_date).getFullYear() : '';
|
||
const type = album.album_type === 'album' ? 'Album' :
|
||
album.album_type === 'single' ? 'Single' : 'EP';
|
||
|
||
// Create a fallback gradient if no image is available
|
||
const backgroundStyle = imageUrl ?
|
||
`background-image: url('${imageUrl}');` :
|
||
`background: linear-gradient(135deg, rgba(29, 185, 84, 0.2) 0%, rgba(24, 156, 71, 0.1) 100%);`;
|
||
|
||
return `
|
||
<div class="album-card" data-album-id="${album.id}" data-album-name="${escapeHtml(album.name)}" data-album-type="${album.album_type}" data-total-tracks="${album.total_tracks || 0}">
|
||
<div class="album-card-image" style="${backgroundStyle}"></div>
|
||
<div class="completion-overlay checking">
|
||
<span class="completion-status">Checking...</span>
|
||
</div>
|
||
<div class="album-card-content">
|
||
<div class="album-card-name" title="${escapeHtml(album.name)}">${escapeHtml(album.name)}</div>
|
||
<div class="album-card-year">${year || 'Unknown'}</div>
|
||
<div class="album-card-type">${type}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Initialize artist detail tabs
|
||
*/
|
||
function initializeArtistTabs() {
|
||
const tabButtons = document.querySelectorAll('.artist-tab');
|
||
const tabContents = document.querySelectorAll('.tab-content');
|
||
|
||
tabButtons.forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
const tabName = button.getAttribute('data-tab');
|
||
|
||
// Update button states
|
||
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||
button.classList.add('active');
|
||
|
||
// Update content states
|
||
tabContents.forEach(content => {
|
||
content.classList.remove('active');
|
||
if (content.id === `${tabName}-content`) {
|
||
content.classList.add('active');
|
||
}
|
||
});
|
||
|
||
console.log(`🔄 Switched to ${tabName} tab`);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* State management functions
|
||
*/
|
||
function showArtistsSearchState() {
|
||
console.log('🔄 Showing search state');
|
||
|
||
const searchState = document.getElementById('artists-search-state');
|
||
const resultsState = document.getElementById('artists-results-state');
|
||
const detailState = document.getElementById('artist-detail-state');
|
||
|
||
if (searchState) {
|
||
searchState.classList.remove('hidden', 'fade-out');
|
||
}
|
||
if (resultsState) {
|
||
resultsState.classList.add('hidden');
|
||
resultsState.classList.remove('show');
|
||
}
|
||
if (detailState) {
|
||
detailState.classList.add('hidden');
|
||
detailState.classList.remove('show');
|
||
}
|
||
|
||
artistsPageState.currentView = 'search';
|
||
updateArtistsSearchStatus('default');
|
||
|
||
// Show artist downloads section if there are active downloads
|
||
showArtistDownloadsSection();
|
||
}
|
||
|
||
function showArtistsResultsState() {
|
||
console.log('🔄 Showing results state');
|
||
|
||
// Clear artist-specific data when navigating back to results
|
||
// This ensures that selecting the same artist again will trigger a fresh scan
|
||
if (artistsPageState.selectedArtist) {
|
||
const artistId = artistsPageState.selectedArtist.id;
|
||
console.log(`🗑️ Clearing cached data for artist: ${artistsPageState.selectedArtist.name}`);
|
||
|
||
// Clear artist-specific cache data
|
||
delete artistsPageState.cache.completionData[artistId];
|
||
delete artistsPageState.cache.discography[artistId];
|
||
|
||
// Clear artist state
|
||
artistsPageState.selectedArtist = null;
|
||
artistsPageState.artistDiscography = { albums: [], singles: [] };
|
||
}
|
||
|
||
const searchState = document.getElementById('artists-search-state');
|
||
const resultsState = document.getElementById('artists-results-state');
|
||
const detailState = document.getElementById('artist-detail-state');
|
||
|
||
if (searchState) {
|
||
searchState.classList.add('fade-out');
|
||
setTimeout(() => searchState.classList.add('hidden'), 200);
|
||
}
|
||
if (resultsState) {
|
||
resultsState.classList.remove('hidden');
|
||
setTimeout(() => resultsState.classList.add('show'), 50);
|
||
}
|
||
if (detailState) {
|
||
detailState.classList.add('hidden');
|
||
detailState.classList.remove('show');
|
||
}
|
||
|
||
artistsPageState.currentView = 'results';
|
||
}
|
||
|
||
function showArtistDetailState() {
|
||
console.log('🔄 Showing detail state');
|
||
|
||
const searchState = document.getElementById('artists-search-state');
|
||
const resultsState = document.getElementById('artists-results-state');
|
||
const detailState = document.getElementById('artist-detail-state');
|
||
|
||
if (searchState) {
|
||
searchState.classList.add('hidden', 'fade-out');
|
||
}
|
||
if (resultsState) {
|
||
resultsState.classList.add('hidden');
|
||
resultsState.classList.remove('show');
|
||
}
|
||
if (detailState) {
|
||
detailState.classList.remove('hidden');
|
||
setTimeout(() => detailState.classList.add('show'), 50);
|
||
}
|
||
|
||
artistsPageState.currentView = 'detail';
|
||
}
|
||
|
||
/**
|
||
* Update search status text and styling
|
||
*/
|
||
function updateArtistsSearchStatus(status, message = null) {
|
||
const statusElement = document.getElementById('artists-search-status');
|
||
if (!statusElement) return;
|
||
|
||
// Clear all status classes
|
||
statusElement.classList.remove('searching', 'error');
|
||
|
||
switch (status) {
|
||
case 'default':
|
||
statusElement.textContent = 'Start typing to search for artists';
|
||
break;
|
||
case 'searching':
|
||
statusElement.classList.add('searching');
|
||
statusElement.textContent = 'Searching for artists...';
|
||
break;
|
||
case 'error':
|
||
statusElement.classList.add('error');
|
||
statusElement.innerHTML = `
|
||
<div style="margin-bottom: 8px;">${message || 'Search failed. Please try again.'}</div>
|
||
<button onclick="retryLastSearch()" style="
|
||
background: rgba(29, 185, 84, 0.15);
|
||
color: rgba(29, 185, 84, 0.9);
|
||
border: 1px solid rgba(29, 185, 84, 0.3);
|
||
border-radius: 8px;
|
||
padding: 4px 12px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
" onmouseover="this.style.background='rgba(29, 185, 84, 0.25)'"
|
||
onmouseout="this.style.background='rgba(29, 185, 84, 0.15)'">
|
||
🔄 Retry Search
|
||
</button>
|
||
`;
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Retry the last search query
|
||
*/
|
||
function retryLastSearch() {
|
||
const searchInput = document.getElementById('artists-search-input');
|
||
const headerSearchInput = document.getElementById('artists-header-search-input');
|
||
|
||
// Get the last search query from either input
|
||
const query = searchInput?.value?.trim() || headerSearchInput?.value?.trim() || artistsPageState.searchQuery;
|
||
|
||
if (query) {
|
||
console.log(`🔄 Retrying search for: "${query}"`);
|
||
performArtistsSearch(query);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update artist detail header with artist info
|
||
*/
|
||
function updateArtistDetailHeader(artist) {
|
||
const imageElement = document.getElementById('artist-detail-image');
|
||
const nameElement = document.getElementById('artist-detail-name');
|
||
const genresElement = document.getElementById('artist-detail-genres');
|
||
|
||
if (imageElement && artist.image_url) {
|
||
imageElement.style.backgroundImage = `url('${artist.image_url}')`;
|
||
}
|
||
|
||
if (nameElement) {
|
||
nameElement.textContent = artist.name;
|
||
}
|
||
|
||
if (genresElement) {
|
||
const genres = artist.genres?.slice(0, 4).join(' • ') || 'Various genres';
|
||
genresElement.textContent = genres;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show loading state for discography
|
||
*/
|
||
function showDiscographyLoading() {
|
||
const albumsContainer = document.getElementById('album-cards-container');
|
||
const singlesContainer = document.getElementById('singles-cards-container');
|
||
|
||
const loadingHtml = `
|
||
<div class="album-card loading">
|
||
<div class="album-card-image"></div>
|
||
<div class="album-card-content">
|
||
<div class="album-card-name">Loading...</div>
|
||
<div class="album-card-year">-</div>
|
||
<div class="album-card-type">-</div>
|
||
</div>
|
||
</div>
|
||
`.repeat(4);
|
||
|
||
if (albumsContainer) albumsContainer.innerHTML = loadingHtml;
|
||
if (singlesContainer) singlesContainer.innerHTML = loadingHtml;
|
||
}
|
||
|
||
/**
|
||
* Show error state for discography
|
||
*/
|
||
function showDiscographyError(message = 'Failed to load discography') {
|
||
const albumsContainer = document.getElementById('album-cards-container');
|
||
const singlesContainer = document.getElementById('singles-cards-container');
|
||
|
||
const errorHtml = `
|
||
<div style="grid-column: 1 / -1; text-align: center; padding: 40px 20px; color: rgba(255, 65, 54, 0.8);">
|
||
<div style="font-size: 18px; margin-bottom: 8px;">⚠️</div>
|
||
<div style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">Failed to load discography</div>
|
||
<div style="font-size: 12px; color: rgba(255, 65, 54, 0.6); max-width: 300px; margin: 0 auto;">${escapeHtml(message)}</div>
|
||
</div>
|
||
`;
|
||
|
||
if (albumsContainer) albumsContainer.innerHTML = errorHtml;
|
||
if (singlesContainer) singlesContainer.innerHTML = errorHtml;
|
||
}
|
||
|
||
/**
|
||
* Show loading cards while searching
|
||
*/
|
||
function showSearchLoadingCards() {
|
||
const container = document.getElementById('artists-cards-container');
|
||
if (!container) return;
|
||
|
||
const loadingCardHtml = `
|
||
<div class="artist-card loading">
|
||
<div class="artist-card-background"></div>
|
||
<div class="artist-card-overlay"></div>
|
||
<div class="artist-card-content">
|
||
<div class="artist-card-name">Loading...</div>
|
||
<div class="artist-card-genres">Fetching data...</div>
|
||
<div class="artist-card-popularity">
|
||
<span class="popularity-icon">⏳</span>
|
||
<span>Loading...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Show 6 loading cards
|
||
container.innerHTML = loadingCardHtml.repeat(6);
|
||
}
|
||
|
||
// ===============================
|
||
// ARTIST ALBUM DOWNLOAD MISSING TRACKS INTEGRATION
|
||
// ===============================
|
||
|
||
/**
|
||
* Handle album/single/EP click to open download missing tracks modal
|
||
*/
|
||
async function handleArtistAlbumClick(album, albumType) {
|
||
console.log(`🎵 Album clicked: ${album.name} (${album.album_type}) from artist: ${artistsPageState.selectedArtist?.name}`);
|
||
|
||
if (!artistsPageState.selectedArtist) {
|
||
console.error('❌ No selected artist found');
|
||
showToast('Error: No artist selected', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Create virtual playlist ID
|
||
const virtualPlaylistId = `artist_album_${artistsPageState.selectedArtist.id}_${album.id}`;
|
||
|
||
// Check if modal already exists and show it
|
||
if (activeDownloadProcesses[virtualPlaylistId]) {
|
||
console.log(`📱 Reopening existing modal for ${album.name}`);
|
||
const process = activeDownloadProcesses[virtualPlaylistId];
|
||
if (process.modalElement) {
|
||
if (process.status === 'complete') {
|
||
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
|
||
}
|
||
process.modalElement.style.display = 'flex';
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Create virtual playlist and open modal
|
||
await createArtistAlbumVirtualPlaylist(album, albumType);
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error opening download missing tracks modal:', error);
|
||
showToast(`Error opening download modal: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create virtual playlist for artist album and open download missing tracks modal
|
||
*/
|
||
async function createArtistAlbumVirtualPlaylist(album, albumType) {
|
||
const artist = artistsPageState.selectedArtist;
|
||
const virtualPlaylistId = `artist_album_${artist.id}_${album.id}`;
|
||
|
||
console.log(`🎵 Creating virtual playlist for: ${artist.name} - ${album.name}`);
|
||
|
||
try {
|
||
// Show loading toast
|
||
showToast(`Loading tracks for ${album.name}...`, 'info');
|
||
|
||
// Fetch album tracks from backend
|
||
const response = await fetch(`/api/artist/${artist.id}/album/${album.id}/tracks`);
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
throw new Error('Spotify not authenticated. Please check your API settings.');
|
||
}
|
||
throw new Error(`Failed to load album tracks: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||
throw new Error('No tracks found for this album');
|
||
}
|
||
|
||
console.log(`✅ Loaded ${data.tracks.length} tracks for ${album.name}`);
|
||
|
||
// Format playlist name with artist and album info
|
||
const playlistName = `[${artist.name}] ${album.name}`;
|
||
|
||
// Open download missing tracks modal with formatted tracks
|
||
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, data.tracks, album, artist);
|
||
|
||
// Track this download for artist bubble management
|
||
registerArtistDownload(artist, album, virtualPlaylistId, albumType);
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error creating virtual playlist:', error);
|
||
showToast(`Failed to load album: ${error.message}`, 'error');
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Open download missing tracks modal specifically for artist albums
|
||
* Similar to openDownloadMissingModalForYouTube but for artist albums
|
||
*/
|
||
async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist) {
|
||
// Check if a process is already active for this virtual playlist
|
||
if (activeDownloadProcesses[virtualPlaylistId]) {
|
||
console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`);
|
||
const process = activeDownloadProcesses[virtualPlaylistId];
|
||
if (process.modalElement) {
|
||
if (process.status === 'complete') {
|
||
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
|
||
}
|
||
process.modalElement.style.display = 'flex';
|
||
}
|
||
return;
|
||
}
|
||
|
||
console.log(`📥 Opening Download Missing Tracks modal for artist album: ${virtualPlaylistId}`);
|
||
|
||
// Create virtual playlist object for compatibility with existing modal logic
|
||
const virtualPlaylist = {
|
||
id: virtualPlaylistId,
|
||
name: playlistName,
|
||
track_count: spotifyTracks.length
|
||
};
|
||
|
||
// Store the tracks in the cache for the modal to use
|
||
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
|
||
currentPlaylistTracks = spotifyTracks;
|
||
currentModalPlaylistId = virtualPlaylistId;
|
||
|
||
let modal = document.createElement('div');
|
||
modal.id = `download-missing-modal-${virtualPlaylistId}`;
|
||
modal.className = 'download-missing-modal';
|
||
modal.style.display = 'none';
|
||
document.body.appendChild(modal);
|
||
|
||
// Register the new process in our global state tracker using the same structure as other modals
|
||
activeDownloadProcesses[virtualPlaylistId] = {
|
||
status: 'idle',
|
||
modalElement: modal,
|
||
poller: null,
|
||
batchId: null,
|
||
playlist: virtualPlaylist,
|
||
tracks: spotifyTracks,
|
||
// Additional metadata for artist albums
|
||
artist: artist,
|
||
album: album,
|
||
albumType: album.album_type
|
||
};
|
||
|
||
// Use the exact same modal HTML structure as the existing modals
|
||
modal.innerHTML = `
|
||
<div class="download-missing-modal-content">
|
||
<div class="download-missing-modal-header">
|
||
<h2 class="download-missing-modal-title">Download Missing Tracks - ${escapeHtml(playlistName)}</h2>
|
||
<span class="download-missing-modal-close" onclick="closeDownloadMissingModal('${virtualPlaylistId}')">×</span>
|
||
</div>
|
||
|
||
<div class="download-missing-modal-body">
|
||
<div class="download-dashboard-stats">
|
||
<div class="dashboard-stat stat-total">
|
||
<div class="dashboard-stat-number" id="stat-total-${virtualPlaylistId}">${spotifyTracks.length}</div>
|
||
<div class="dashboard-stat-label">Total Tracks</div>
|
||
</div>
|
||
<div class="dashboard-stat stat-found">
|
||
<div class="dashboard-stat-number" id="stat-found-${virtualPlaylistId}">-</div>
|
||
<div class="dashboard-stat-label">Found in Library</div>
|
||
</div>
|
||
<div class="dashboard-stat stat-missing">
|
||
<div class="dashboard-stat-number" id="stat-missing-${virtualPlaylistId}">-</div>
|
||
<div class="dashboard-stat-label">Missing Tracks</div>
|
||
</div>
|
||
<div class="dashboard-stat stat-downloaded">
|
||
<div class="dashboard-stat-number" id="stat-downloaded-${virtualPlaylistId}">0</div>
|
||
<div class="dashboard-stat-label">Downloaded</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-progress-section">
|
||
<div class="progress-item">
|
||
<div class="progress-label">
|
||
🔍 Library Analysis
|
||
<span id="analysis-progress-text-${virtualPlaylistId}">Ready to start</span>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill analysis" id="analysis-progress-fill-${virtualPlaylistId}"></div>
|
||
</div>
|
||
</div>
|
||
<div class="progress-item">
|
||
<div class="progress-label">
|
||
⏬ Downloads
|
||
<span id="download-progress-text-${virtualPlaylistId}">Waiting for analysis</span>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill download" id="download-progress-fill-${virtualPlaylistId}"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-tracks-section">
|
||
<div class="download-tracks-header">
|
||
<h3 class="download-tracks-title">📋 Track Analysis & Download Status</h3>
|
||
</div>
|
||
<div class="download-tracks-table-container">
|
||
<table class="download-tracks-table">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Track Name</th>
|
||
<th>Artist(s)</th>
|
||
<th>Duration</th>
|
||
<th>Library Status</th>
|
||
<th>Download Status</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="download-tracks-tbody-${virtualPlaylistId}">
|
||
${spotifyTracks.map((track, index) => `
|
||
<tr data-track-index="${index}">
|
||
<td class="track-number">${index + 1}</td>
|
||
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
|
||
<td class="track-artist" title="${escapeHtml(track.artists.join(', '))}">${track.artists.join(', ')}</td>
|
||
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
|
||
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
|
||
<td class="track-download-status" id="download-${virtualPlaylistId}-${index}">-</td>
|
||
<td class="track-actions" id="actions-${virtualPlaylistId}-${index}">-</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="download-missing-modal-footer">
|
||
<div class="download-phase-controls">
|
||
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
|
||
Begin Analysis
|
||
</button>
|
||
<button class="download-control-btn danger" id="cancel-all-btn-${virtualPlaylistId}" onclick="cancelAllOperations('${virtualPlaylistId}')" style="display: none;">
|
||
Cancel All
|
||
</button>
|
||
</div>
|
||
<div class="modal-close-section">
|
||
<button class="download-control-btn secondary" onclick="closeDownloadMissingModal('${virtualPlaylistId}')">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
modal.style.display = 'flex';
|
||
|
||
console.log(`✅ Successfully opened download missing tracks modal for: ${playlistName}`);
|
||
}
|
||
|
||
// ===============================
|
||
// ARTIST DOWNLOADS MANAGEMENT SYSTEM
|
||
// ===============================
|
||
|
||
/**
|
||
* Register a new artist download for bubble management
|
||
*/
|
||
function registerArtistDownload(artist, album, virtualPlaylistId, albumType) {
|
||
console.log(`📝 Registering artist download: ${artist.name} - ${album.name}`);
|
||
|
||
const artistId = artist.id;
|
||
|
||
// Initialize artist bubble if it doesn't exist
|
||
if (!artistDownloadBubbles[artistId]) {
|
||
artistDownloadBubbles[artistId] = {
|
||
artist: artist,
|
||
downloads: [],
|
||
element: null,
|
||
hasCompletedDownloads: false
|
||
};
|
||
}
|
||
|
||
// Add this download to the artist's downloads
|
||
const downloadInfo = {
|
||
virtualPlaylistId: virtualPlaylistId,
|
||
album: album,
|
||
albumType: albumType,
|
||
status: 'in_progress', // 'in_progress', 'completed', 'view_results'
|
||
startTime: new Date()
|
||
};
|
||
|
||
artistDownloadBubbles[artistId].downloads.push(downloadInfo);
|
||
|
||
// Show/update the artist downloads section
|
||
updateArtistDownloadsSection();
|
||
|
||
// Save snapshot of current state
|
||
saveArtistBubbleSnapshot();
|
||
|
||
// Monitor this download for completion
|
||
monitorArtistDownload(artistId, virtualPlaylistId);
|
||
}
|
||
|
||
/**
|
||
* Debounced update for artist downloads section to prevent rapid updates
|
||
*/
|
||
function updateArtistDownloadsSection() {
|
||
if (downloadsUpdateTimeout) {
|
||
clearTimeout(downloadsUpdateTimeout);
|
||
}
|
||
downloadsUpdateTimeout = setTimeout(() => {
|
||
showArtistDownloadsSection();
|
||
}, 300); // 300ms debounce
|
||
}
|
||
|
||
// --- Artist Bubble Snapshot System ---
|
||
|
||
let snapshotSaveTimeout = null; // Debounce snapshot saves
|
||
|
||
async function saveArtistBubbleSnapshot() {
|
||
/**
|
||
* Saves current artistDownloadBubbles state to backend for persistence.
|
||
* Debounced to prevent excessive backend calls.
|
||
*/
|
||
|
||
// Clear any existing timeout
|
||
if (snapshotSaveTimeout) {
|
||
clearTimeout(snapshotSaveTimeout);
|
||
}
|
||
|
||
// Debounce the actual save
|
||
snapshotSaveTimeout = setTimeout(async () => {
|
||
try {
|
||
const bubbleCount = Object.keys(artistDownloadBubbles).length;
|
||
|
||
// Don't save empty state
|
||
if (bubbleCount === 0) {
|
||
console.log('📸 Skipping snapshot save - no artist bubbles to save');
|
||
return;
|
||
}
|
||
|
||
console.log(`📸 Saving artist bubble snapshot: ${bubbleCount} artists`);
|
||
|
||
// Prepare snapshot data (clean up DOM references)
|
||
const cleanBubbles = {};
|
||
for (const [artistId, bubbleData] of Object.entries(artistDownloadBubbles)) {
|
||
cleanBubbles[artistId] = {
|
||
artist: bubbleData.artist,
|
||
downloads: bubbleData.downloads.map(download => ({
|
||
virtualPlaylistId: download.virtualPlaylistId,
|
||
album: download.album,
|
||
albumType: download.albumType,
|
||
status: download.status,
|
||
startTime: download.startTime instanceof Date ? download.startTime.toISOString() : download.startTime
|
||
})),
|
||
hasCompletedDownloads: bubbleData.hasCompletedDownloads
|
||
};
|
||
}
|
||
|
||
const response = await fetch('/api/artist_bubbles/snapshot', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
bubbles: cleanBubbles
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
console.log(`✅ Artist bubble snapshot saved: ${bubbleCount} artists`);
|
||
} else {
|
||
console.error('❌ Failed to save artist bubble snapshot:', data.error);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error saving artist bubble snapshot:', error);
|
||
}
|
||
}, 1000); // 1 second debounce
|
||
}
|
||
|
||
async function hydrateArtistBubblesFromSnapshot() {
|
||
/**
|
||
* Hydrates artist download bubbles from backend snapshot with live status.
|
||
* Called on page load to restore bubble state.
|
||
*/
|
||
try {
|
||
console.log('🔄 Loading artist bubble snapshot from backend...');
|
||
|
||
const response = await fetch('/api/artist_bubbles/hydrate');
|
||
const data = await response.json();
|
||
|
||
if (!data.success) {
|
||
console.error('❌ Failed to load artist bubble snapshot:', data.error);
|
||
return;
|
||
}
|
||
|
||
const bubbles = data.bubbles || {};
|
||
const stats = data.stats || {};
|
||
|
||
console.log(`🔄 Loaded bubble snapshot: ${stats.total_artists || 0} artists, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`);
|
||
|
||
if (Object.keys(bubbles).length === 0) {
|
||
console.log('ℹ️ No artist bubbles to hydrate');
|
||
return;
|
||
}
|
||
|
||
// Clear existing state
|
||
artistDownloadBubbles = {};
|
||
|
||
// Restore artistDownloadBubbles with hydrated data
|
||
for (const [artistId, bubbleData] of Object.entries(bubbles)) {
|
||
artistDownloadBubbles[artistId] = {
|
||
artist: bubbleData.artist,
|
||
downloads: bubbleData.downloads.map(download => ({
|
||
virtualPlaylistId: download.virtualPlaylistId,
|
||
album: download.album,
|
||
albumType: download.albumType,
|
||
status: download.status, // Live status from backend
|
||
startTime: new Date(download.startTime)
|
||
})),
|
||
element: null, // Will be created when UI updates
|
||
hasCompletedDownloads: bubbleData.hasCompletedDownloads
|
||
};
|
||
|
||
console.log(`🔄 Hydrated artist: ${bubbleData.artist.name} (${bubbleData.downloads.length} downloads)`);
|
||
|
||
// Start monitoring for any in-progress downloads
|
||
for (const download of bubbleData.downloads) {
|
||
if (download.status === 'in_progress') {
|
||
console.log(`📡 Starting monitoring for: ${download.album.name}`);
|
||
monitorArtistDownload(artistId, download.virtualPlaylistId);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update UI to show hydrated bubbles
|
||
updateArtistDownloadsSection();
|
||
|
||
const totalArtists = Object.keys(artistDownloadBubbles).length;
|
||
console.log(`✅ Successfully hydrated ${totalArtists} artist download bubbles`);
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error hydrating artist bubbles from snapshot:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show or update the artist downloads section in search state
|
||
*/
|
||
function showArtistDownloadsSection() {
|
||
console.log(`🔄 [SHOW] showArtistDownloadsSection() called - refreshing artist bubbles`);
|
||
console.log(`🔄 [SHOW] Current view: ${artistsPageState.currentView}, artistDownloadBubbles count: ${Object.keys(artistDownloadBubbles).length}`);
|
||
|
||
// Only show in search state
|
||
if (artistsPageState.currentView !== 'search') {
|
||
console.log(`⏭️ [SHOW] Skipping - not in search state (current: ${artistsPageState.currentView})`);
|
||
return;
|
||
}
|
||
|
||
const artistsSearchState = document.getElementById('artists-search-state');
|
||
if (!artistsSearchState) {
|
||
console.log(`⏭️ [SHOW] Skipping - no artists-search-state element found`);
|
||
return;
|
||
}
|
||
|
||
let downloadsSection = document.getElementById('artist-downloads-section');
|
||
|
||
// Create section if it doesn't exist
|
||
if (!downloadsSection) {
|
||
downloadsSection = document.createElement('div');
|
||
downloadsSection.id = 'artist-downloads-section';
|
||
downloadsSection.className = 'artist-downloads-section';
|
||
|
||
// Insert after the search container
|
||
const searchContainer = artistsSearchState.querySelector('.artists-search-container');
|
||
if (searchContainer) {
|
||
searchContainer.insertAdjacentElement('afterend', downloadsSection);
|
||
}
|
||
}
|
||
|
||
// Count active artists (those with downloads)
|
||
const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId =>
|
||
artistDownloadBubbles[artistId].downloads.length > 0
|
||
);
|
||
|
||
if (activeArtists.length === 0) {
|
||
downloadsSection.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
// Show and populate the section
|
||
downloadsSection.style.display = 'block';
|
||
downloadsSection.innerHTML = `
|
||
<div class="artist-downloads-header">
|
||
<h3 class="artist-downloads-title">Current Downloads</h3>
|
||
<p class="artist-downloads-subtitle">Active download processes</p>
|
||
</div>
|
||
<div class="artist-bubble-container" id="artist-bubble-container">
|
||
${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')}
|
||
</div>
|
||
`;
|
||
|
||
// Add event listeners to bubble cards
|
||
activeArtists.forEach(artistId => {
|
||
const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`);
|
||
if (bubbleCard) {
|
||
bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId));
|
||
|
||
// Add dynamic glow effect
|
||
const artist = artistDownloadBubbles[artistId].artist;
|
||
if (artist.image_url) {
|
||
extractImageColors(artist.image_url, (colors) => {
|
||
applyDynamicGlow(bubbleCard, colors);
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Create HTML for an artist bubble card
|
||
*/
|
||
function createArtistBubbleCard(artistBubbleData) {
|
||
const { artist, downloads } = artistBubbleData;
|
||
const activeCount = downloads.filter(d => d.status === 'in_progress').length;
|
||
const completedCount = downloads.filter(d => d.status === 'view_results').length;
|
||
const allCompleted = activeCount === 0 && completedCount > 0;
|
||
|
||
// Enhanced debug logging for bubble card creation and green checkmark detection
|
||
console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, {
|
||
totalDownloads: downloads.length,
|
||
activeCount,
|
||
completedCount,
|
||
allCompleted,
|
||
downloadStatuses: downloads.map(d => `${d.album.name}: ${d.status}`)
|
||
});
|
||
|
||
// CRITICAL: Green checkmark detection logging
|
||
if (allCompleted) {
|
||
console.log(`🟢 [BUBBLE] GREEN CHECKMARK DETECTED for ${artist.name} - all ${downloads.length} downloads completed`);
|
||
console.log(`✅ [BUBBLE] This bubble will have 'all-completed' class and green checkmark`);
|
||
} else if (activeCount === 0 && completedCount === 0) {
|
||
console.log(`⭕ [BUBBLE] No active or completed downloads for ${artist.name} - this shouldn't happen`);
|
||
} else {
|
||
console.log(`⏳ [BUBBLE] Still waiting for completion: ${activeCount} active, ${completedCount} completed`);
|
||
}
|
||
|
||
const imageUrl = artist.image_url || '';
|
||
const backgroundStyle = imageUrl ?
|
||
`background-image: url('${imageUrl}');` :
|
||
`background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
|
||
|
||
return `
|
||
<div class="artist-bubble-card ${allCompleted ? 'all-completed' : ''}"
|
||
data-artist-id="${artist.id}"
|
||
title="Click to manage downloads for ${escapeHtml(artist.name)}">
|
||
<div class="artist-bubble-image" style="${backgroundStyle}"></div>
|
||
<div class="artist-bubble-overlay"></div>
|
||
<div class="artist-bubble-content">
|
||
<div class="artist-bubble-name">${escapeHtml(artist.name)}</div>
|
||
<div class="artist-bubble-status">
|
||
${activeCount > 0 ? `${activeCount} active` : ''}
|
||
${completedCount > 0 ? `${completedCount} completed` : ''}
|
||
</div>
|
||
</div>
|
||
${allCompleted ? `
|
||
<div class="bulk-complete-indicator"
|
||
onclick="event.stopPropagation(); bulkCompleteArtistDownloads('${artist.id}')"
|
||
title="Complete all downloads">
|
||
<span class="bulk-complete-icon">✅</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Monitor an artist download for completion status changes
|
||
*/
|
||
function monitorArtistDownload(artistId, virtualPlaylistId) {
|
||
// Check if the download process exists and monitor its status
|
||
const checkStatus = () => {
|
||
const process = activeDownloadProcesses[virtualPlaylistId];
|
||
if (!process || !artistDownloadBubbles[artistId]) {
|
||
return; // Process or artist bubble no longer exists
|
||
}
|
||
|
||
// Find this download in the artist's downloads
|
||
const download = artistDownloadBubbles[artistId].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId);
|
||
if (!download) return;
|
||
|
||
// Update download status based on process status
|
||
if (process.status === 'complete' && download.status === 'in_progress') {
|
||
download.status = 'view_results';
|
||
console.log(`✅ Download completed for ${artistDownloadBubbles[artistId].artist.name} - ${download.album.name}`);
|
||
console.log(`📊 Artist ${artistId} downloads status:`, artistDownloadBubbles[artistId].downloads.map(d => `${d.album.name}: ${d.status}`));
|
||
|
||
// Update the downloads section
|
||
updateArtistDownloadsSection();
|
||
|
||
// Save snapshot of updated state
|
||
saveArtistBubbleSnapshot();
|
||
|
||
// Check if all downloads for this artist are now completed
|
||
const artistDownloads = artistDownloadBubbles[artistId].downloads;
|
||
const allCompleted = artistDownloads.every(d => d.status === 'view_results');
|
||
if (allCompleted) {
|
||
console.log(`🟢 All downloads completed for ${artistDownloadBubbles[artistId].artist.name} - green checkmark should appear`);
|
||
console.log(`🎯 [STATUS DEBUG] Green checkmark trigger - forcing bubble refresh`);
|
||
// Force immediate bubble refresh to show green checkmark
|
||
setTimeout(updateArtistDownloadsSection, 100);
|
||
}
|
||
}
|
||
|
||
// Continue monitoring if still active
|
||
if (process.status !== 'complete') {
|
||
setTimeout(checkStatus, 2000); // Check every 2 seconds
|
||
}
|
||
};
|
||
|
||
// Start monitoring after a brief delay
|
||
setTimeout(checkStatus, 1000);
|
||
}
|
||
|
||
/**
|
||
* Open the artist download management modal
|
||
*/
|
||
function openArtistDownloadModal(artistId) {
|
||
const artistBubbleData = artistDownloadBubbles[artistId];
|
||
if (!artistBubbleData || artistDownloadModalOpen) return;
|
||
|
||
console.log(`🎵 [MODAL OPEN] Opening artist download modal for: ${artistBubbleData.artist.name}`);
|
||
console.log(`📊 [MODAL OPEN] Current download statuses:`, artistBubbleData.downloads.map(d => `${d.album.name}: ${d.status}`));
|
||
artistDownloadModalOpen = true;
|
||
|
||
const modal = document.createElement('div');
|
||
modal.id = 'artist-download-management-modal';
|
||
modal.className = 'artist-download-management-modal';
|
||
modal.innerHTML = `
|
||
<div class="artist-download-modal-content">
|
||
<div class="artist-download-modal-header">
|
||
<h2 class="artist-download-modal-title">Downloads for ${escapeHtml(artistBubbleData.artist.name)}</h2>
|
||
<span class="artist-download-modal-close" onclick="closeArtistDownloadModal()">×</span>
|
||
</div>
|
||
|
||
<div class="artist-download-modal-body">
|
||
<div class="artist-download-items" id="artist-download-items-${artistId}">
|
||
${artistBubbleData.downloads.map((download, index) => createArtistDownloadItem(download, index)).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="artist-download-modal-overlay" onclick="closeArtistDownloadModal()"></div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
modal.style.display = 'flex';
|
||
|
||
// Monitor for real-time updates
|
||
startArtistDownloadModalMonitoring(artistId);
|
||
}
|
||
|
||
/**
|
||
* Create HTML for an individual download item in the artist modal
|
||
*/
|
||
function createArtistDownloadItem(download, index) {
|
||
const { album, albumType, status, virtualPlaylistId } = download;
|
||
const buttonText = status === 'view_results' ? 'View Results' : 'View Progress';
|
||
const buttonClass = status === 'view_results' ? 'completed' : 'active';
|
||
|
||
// Enhanced debugging for button text generation
|
||
console.log(`🎯 [BUTTON] Creating item for ${album.name}: status='${status}' → buttonText='${buttonText}'`);
|
||
|
||
return `
|
||
<div class="artist-download-item" data-playlist-id="${virtualPlaylistId}">
|
||
<div class="download-item-info">
|
||
<div class="download-item-name">${escapeHtml(album.name)}</div>
|
||
<div class="download-item-type">${albumType === 'album' ? 'Album' : albumType === 'single' ? 'Single' : 'EP'}</div>
|
||
</div>
|
||
<div class="download-item-actions">
|
||
<button class="download-item-btn ${buttonClass}"
|
||
onclick="openArtistDownloadProcess('${virtualPlaylistId}')">
|
||
${buttonText}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Monitor artist download modal for real-time updates
|
||
*/
|
||
function startArtistDownloadModalMonitoring(artistId) {
|
||
if (!artistDownloadModalOpen) return;
|
||
|
||
const updateModal = () => {
|
||
const modal = document.getElementById('artist-download-management-modal');
|
||
const itemsContainer = document.getElementById(`artist-download-items-${artistId}`);
|
||
|
||
if (!modal || !itemsContainer || !artistDownloadBubbles[artistId]) return;
|
||
|
||
// Check for completed downloads that need to be removed
|
||
const activeDownloads = artistDownloadBubbles[artistId].downloads.filter(download => {
|
||
const process = activeDownloadProcesses[download.virtualPlaylistId];
|
||
// Keep if process exists or if it's completed but not yet cleaned up
|
||
return process !== undefined;
|
||
});
|
||
|
||
// Update the downloads array
|
||
artistDownloadBubbles[artistId].downloads = activeDownloads;
|
||
|
||
// If no downloads left, close modal
|
||
if (activeDownloads.length === 0) {
|
||
closeArtistDownloadModal();
|
||
return;
|
||
}
|
||
|
||
// Update modal content and synchronize with bubble state
|
||
let statusChanged = false;
|
||
itemsContainer.innerHTML = activeDownloads.map((download, index) => {
|
||
const process = activeDownloadProcesses[download.virtualPlaylistId];
|
||
if (process) {
|
||
const newStatus = process.status === 'complete' ? 'view_results' : 'in_progress';
|
||
if (download.status !== newStatus) {
|
||
console.log(`🔄 [ARTIST MODAL] Updating ${download.album.name} status from ${download.status} to ${newStatus}`);
|
||
download.status = newStatus;
|
||
statusChanged = true;
|
||
}
|
||
}
|
||
return createArtistDownloadItem(download, index);
|
||
}).join('');
|
||
|
||
// CRITICAL: If any status changed, immediately refresh artist bubble to show green checkmarks
|
||
if (statusChanged) {
|
||
console.log(`🎯 [SYNC] Status change detected in artist modal - refreshing bubble display`);
|
||
updateArtistDownloadsSection();
|
||
|
||
// Check if all downloads for this artist are now completed
|
||
const artistDownloads = artistDownloadBubbles[artistId].downloads;
|
||
const allCompleted = artistDownloads.every(d => d.status === 'view_results');
|
||
if (allCompleted) {
|
||
console.log(`🟢 [ARTIST MODAL] All downloads completed for artist ${artistId} - triggering green checkmark`);
|
||
// Force additional refresh after a brief delay to ensure UI updates
|
||
setTimeout(() => {
|
||
console.log(`✨ [ARTIST MODAL] Forcing final refresh for green checkmark`);
|
||
updateArtistDownloadsSection();
|
||
}, 200);
|
||
}
|
||
}
|
||
|
||
// Continue monitoring
|
||
setTimeout(updateModal, 2000);
|
||
};
|
||
|
||
setTimeout(updateModal, 1000);
|
||
}
|
||
|
||
/**
|
||
* Open a specific artist download process modal
|
||
*/
|
||
function openArtistDownloadProcess(virtualPlaylistId) {
|
||
const process = activeDownloadProcesses[virtualPlaylistId];
|
||
if (process && process.modalElement) {
|
||
// Close artist management modal first
|
||
closeArtistDownloadModal();
|
||
|
||
// Show the download process modal
|
||
process.modalElement.style.display = 'flex';
|
||
|
||
if (process.status === 'complete') {
|
||
showToast('Review download results and click "Close" to finish.', 'info');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Close the artist download management modal
|
||
*/
|
||
function closeArtistDownloadModal() {
|
||
const modal = document.getElementById('artist-download-management-modal');
|
||
if (modal) {
|
||
modal.remove();
|
||
}
|
||
artistDownloadModalOpen = false;
|
||
}
|
||
|
||
/**
|
||
* Bulk complete all downloads for an artist (when all are in 'view_results' state)
|
||
*/
|
||
function bulkCompleteArtistDownloads(artistId) {
|
||
console.log(`🎯 Bulk completing downloads for artist: ${artistId}`);
|
||
|
||
const artistBubbleData = artistDownloadBubbles[artistId];
|
||
if (!artistBubbleData) {
|
||
console.warn(`❌ No artist bubble data found for ${artistId}`);
|
||
return;
|
||
}
|
||
|
||
// Find all downloads in 'view_results' state
|
||
const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results');
|
||
console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`,
|
||
completedDownloads.map(d => d.album.name));
|
||
|
||
if (completedDownloads.length === 0) {
|
||
console.warn(`⚠️ No completed downloads found for bulk close`);
|
||
showToast('No completed downloads to close', 'info');
|
||
return;
|
||
}
|
||
|
||
// Programmatically close all completed modals
|
||
completedDownloads.forEach(download => {
|
||
const process = activeDownloadProcesses[download.virtualPlaylistId];
|
||
if (process && process.modalElement) {
|
||
console.log(`🗑️ Closing modal for: ${download.album.name}`);
|
||
// Trigger the close function which handles cleanup
|
||
closeDownloadMissingModal(download.virtualPlaylistId);
|
||
} else {
|
||
console.warn(`⚠️ No active process or modal found for: ${download.album.name}`);
|
||
}
|
||
});
|
||
|
||
showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success');
|
||
}
|
||
|
||
/**
|
||
* Clean up artist download when a modal is closed
|
||
*/
|
||
function cleanupArtistDownload(virtualPlaylistId) {
|
||
console.log(`🔍 [CLEANUP] Looking for download to cleanup: ${virtualPlaylistId}`);
|
||
console.log(`🔍 [CLEANUP] Current artist bubbles:`, Object.keys(artistDownloadBubbles));
|
||
|
||
// Find which artist this download belongs to
|
||
for (const artistId in artistDownloadBubbles) {
|
||
const downloads = artistDownloadBubbles[artistId].downloads;
|
||
const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId);
|
||
|
||
console.log(`🔍 [CLEANUP] Checking artist ${artistId}: ${downloads.length} downloads`);
|
||
downloads.forEach(d => console.log(` - ${d.album.name} (${d.virtualPlaylistId}): ${d.status}`));
|
||
|
||
if (downloadIndex !== -1) {
|
||
const downloadToRemove = downloads[downloadIndex];
|
||
console.log(`🧹 [CLEANUP] Found download to cleanup: ${downloadToRemove.album.name} (status: ${downloadToRemove.status})`);
|
||
|
||
// Remove this download from the artist's downloads
|
||
downloads.splice(downloadIndex, 1);
|
||
console.log(`✅ [CLEANUP] Removed download from artist ${artistId}. Remaining: ${downloads.length}`);
|
||
|
||
// If no more downloads for this artist, remove the bubble
|
||
if (downloads.length === 0) {
|
||
delete artistDownloadBubbles[artistId];
|
||
console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistId}`);
|
||
} else {
|
||
console.log(`📊 [CLEANUP] Artist ${artistId} still has ${downloads.length} downloads remaining`);
|
||
}
|
||
|
||
// Update the downloads section
|
||
console.log(`🔄 [CLEANUP] Updating artist downloads section...`);
|
||
updateArtistDownloadsSection();
|
||
|
||
// Save snapshot of updated state
|
||
saveArtistBubbleSnapshot();
|
||
break;
|
||
}
|
||
}
|
||
console.log(`✅ [CLEANUP] Cleanup process completed for ${virtualPlaylistId}`);
|
||
}
|
||
|
||
/**
|
||
* Force refresh all artist download statuses (useful for debugging)
|
||
*/
|
||
function refreshAllArtistDownloadStatuses() {
|
||
console.log('🔄 Force refreshing all artist download statuses...');
|
||
|
||
for (const artistId in artistDownloadBubbles) {
|
||
const artistData = artistDownloadBubbles[artistId];
|
||
let hasChanges = false;
|
||
|
||
artistData.downloads.forEach(download => {
|
||
const process = activeDownloadProcesses[download.virtualPlaylistId];
|
||
if (process) {
|
||
const expectedStatus = process.status === 'complete' ? 'view_results' : 'in_progress';
|
||
if (download.status !== expectedStatus) {
|
||
console.log(`🔧 Fixing status for ${download.album.name}: ${download.status} → ${expectedStatus}`);
|
||
download.status = expectedStatus;
|
||
hasChanges = true;
|
||
}
|
||
}
|
||
});
|
||
|
||
if (hasChanges) {
|
||
console.log(`✅ Updated statuses for ${artistData.artist.name}`);
|
||
}
|
||
}
|
||
|
||
// Force update the downloads section
|
||
showArtistDownloadsSection();
|
||
}
|
||
|
||
/**
|
||
* Extract dominant colors from an image for dynamic glow effects
|
||
*/
|
||
async function extractImageColors(imageUrl, callback) {
|
||
if (!imageUrl) {
|
||
callback(['#1db954', '#1ed760']); // Fallback to Spotify green
|
||
return;
|
||
}
|
||
|
||
// Check cache first for performance
|
||
if (artistsPageState.cache.colors[imageUrl]) {
|
||
callback(artistsPageState.cache.colors[imageUrl]);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Create a canvas to analyze the image
|
||
const canvas = document.createElement('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const img = new Image();
|
||
|
||
img.crossOrigin = 'anonymous';
|
||
|
||
img.onload = function() {
|
||
// Resize to small dimensions for faster processing
|
||
const size = 50;
|
||
canvas.width = size;
|
||
canvas.height = size;
|
||
|
||
// Draw image to canvas
|
||
ctx.drawImage(img, 0, 0, size, size);
|
||
|
||
try {
|
||
// Get image data
|
||
const imageData = ctx.getImageData(0, 0, size, size);
|
||
const data = imageData.data;
|
||
|
||
// Extract colors (sample every few pixels for performance)
|
||
const colors = [];
|
||
for (let i = 0; i < data.length; i += 16) { // Sample every 4th pixel
|
||
const r = data[i];
|
||
const g = data[i + 1];
|
||
const b = data[i + 2];
|
||
const alpha = data[i + 3];
|
||
|
||
// Skip transparent or very dark pixels
|
||
if (alpha > 128 && (r + g + b) > 150) {
|
||
colors.push({ r, g, b });
|
||
}
|
||
}
|
||
|
||
if (colors.length === 0) {
|
||
callback(['#1db954', '#1ed760']); // Fallback
|
||
return;
|
||
}
|
||
|
||
// Find dominant colors using a simple clustering approach
|
||
const dominantColors = findDominantColors(colors, 2);
|
||
|
||
// Convert to CSS hex colors
|
||
const hexColors = dominantColors.map(color =>
|
||
`#${((1 << 24) + (color.r << 16) + (color.g << 8) + color.b).toString(16).slice(1)}`
|
||
);
|
||
|
||
// Cache the colors for future use
|
||
artistsPageState.cache.colors[imageUrl] = hexColors;
|
||
|
||
callback(hexColors);
|
||
|
||
} catch (e) {
|
||
console.warn('Color extraction failed, using fallback colors:', e);
|
||
callback(['#1db954', '#1ed760']);
|
||
}
|
||
};
|
||
|
||
img.onerror = function() {
|
||
callback(['#1db954', '#1ed760']); // Fallback on error
|
||
};
|
||
|
||
img.src = imageUrl;
|
||
|
||
} catch (error) {
|
||
console.warn('Image color extraction error:', error);
|
||
callback(['#1db954', '#1ed760']);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Simple color clustering to find dominant colors
|
||
*/
|
||
function findDominantColors(colors, numColors = 2) {
|
||
if (colors.length === 0) return [{ r: 29, g: 185, b: 84 }];
|
||
|
||
// Simple k-means clustering
|
||
let centroids = [];
|
||
|
||
// Initialize centroids randomly
|
||
for (let i = 0; i < numColors; i++) {
|
||
centroids.push(colors[Math.floor(Math.random() * colors.length)]);
|
||
}
|
||
|
||
// Run a few iterations of k-means
|
||
for (let iteration = 0; iteration < 5; iteration++) {
|
||
const clusters = Array(numColors).fill().map(() => []);
|
||
|
||
// Assign each color to nearest centroid
|
||
colors.forEach(color => {
|
||
let minDistance = Infinity;
|
||
let nearestCluster = 0;
|
||
|
||
centroids.forEach((centroid, i) => {
|
||
const distance = Math.sqrt(
|
||
Math.pow(color.r - centroid.r, 2) +
|
||
Math.pow(color.g - centroid.g, 2) +
|
||
Math.pow(color.b - centroid.b, 2)
|
||
);
|
||
|
||
if (distance < minDistance) {
|
||
minDistance = distance;
|
||
nearestCluster = i;
|
||
}
|
||
});
|
||
|
||
clusters[nearestCluster].push(color);
|
||
});
|
||
|
||
// Update centroids
|
||
centroids = clusters.map(cluster => {
|
||
if (cluster.length === 0) return centroids[0]; // Fallback
|
||
|
||
const avgR = cluster.reduce((sum, c) => sum + c.r, 0) / cluster.length;
|
||
const avgG = cluster.reduce((sum, c) => sum + c.g, 0) / cluster.length;
|
||
const avgB = cluster.reduce((sum, c) => sum + c.b, 0) / cluster.length;
|
||
|
||
return { r: Math.round(avgR), g: Math.round(avgG), b: Math.round(avgB) };
|
||
});
|
||
}
|
||
|
||
// Ensure we have vibrant colors by boosting saturation
|
||
return centroids.map(color => {
|
||
const max = Math.max(color.r, color.g, color.b);
|
||
const min = Math.min(color.r, color.g, color.b);
|
||
const saturation = max === 0 ? 0 : (max - min) / max;
|
||
|
||
// Boost low saturation colors
|
||
if (saturation < 0.4) {
|
||
const factor = 1.3;
|
||
return {
|
||
r: Math.min(255, Math.round(color.r * factor)),
|
||
g: Math.min(255, Math.round(color.g * factor)),
|
||
b: Math.min(255, Math.round(color.b * factor))
|
||
};
|
||
}
|
||
|
||
return color;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Apply dynamic glow effect to a card element
|
||
*/
|
||
function applyDynamicGlow(cardElement, colors) {
|
||
if (!cardElement || colors.length < 2) return;
|
||
|
||
const color1 = colors[0];
|
||
const color2 = colors[1];
|
||
|
||
// Add a small delay to make the effect feel more natural
|
||
setTimeout(() => {
|
||
// Create CSS custom properties for the dynamic colors
|
||
cardElement.style.setProperty('--glow-color-1', color1);
|
||
cardElement.style.setProperty('--glow-color-2', color2);
|
||
cardElement.classList.add('has-dynamic-glow');
|
||
|
||
console.log(`🎨 Applied dynamic glow: ${color1}, ${color2}`);
|
||
}, Math.random() * 200 + 100); // Random delay between 100-300ms
|
||
}
|
||
|
||
/**
|
||
* Utility function to escape HTML
|
||
*/
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
|
||
// --- Global Cleanup on Page Unload ---
|
||
// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed
|