// SoulSync WebUI JavaScript - Replicating PyQt6 GUI Functionality // Global state management let currentPage = 'dashboard'; let currentTrack = null; let isPlaying = false; let mediaPlayerExpanded = false; let searchResults = []; let currentStream = { status: 'stopped', progress: 0, track: null }; let currentMusicSourceName = 'Spotify'; // 'Spotify', 'iTunes', or 'Deezer' - updated from status endpoint // 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 qualityScannerStatusInterval = null; let duplicateCleanerStatusInterval = null; let wishlistCountInterval = null; let wishlistCountdownInterval = null; // Countdown timer for wishlist overview modal let watchlistCountdownInterval = null; // Countdown timer for watchlist overview modal // Page state for Watchlist & Wishlist sidebar pages let watchlistPageState = { isInitialized: false, artists: [] }; let wishlistPageState = { isInitialized: false }; // --- Add these globals for the Sync Page --- let spotifyPlaylists = []; let selectedPlaylists = new Set(); let activeSyncPollers = {}; // Key: playlist_id, Value: intervalId // Phase 5: WebSocket sync/discovery/scan state let _syncProgressCallbacks = {}; let _discoveryProgressCallbacks = {}; let _lastWatchlistScanStatus = null; let _lastMediaScanStatus = null; let _lastWishlistStats = null; 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; let deezerPlaylists = []; let deezerPlaylistStates = {}; let deezerArlPlaylists = []; let deezerArlPlaylistsLoaded = false; // --- Beatport Chart State Management (Similar to YouTube/Tidal) --- let beatportChartStates = {}; // Key: chart_hash, Value: chart state with phases let beatportContentState = { loaded: false, loadingPromise: null, abortController: null }; function getBeatportContentSignal() { return beatportContentState.abortController ? beatportContentState.abortController.signal : null; } function throwIfBeatportLoadAborted() { if (beatportContentState.abortController && beatportContentState.abortController.signal.aborted) { throw new DOMException('Beatport load aborted', 'AbortError'); } } function stopBeatportDiscoveryAndSyncPolling() { Object.entries(activeYouTubePollers).forEach(([identifier, poller]) => { const isBeatportChart = !!youtubePlaylistStates[identifier]?.is_beatport_playlist || !!beatportChartStates[identifier]; if (isBeatportChart) { clearInterval(poller); delete activeYouTubePollers[identifier]; } }); Object.entries(_discoveryProgressCallbacks).forEach(([identifier]) => { const isBeatportChart = !!youtubePlaylistStates[identifier]?.is_beatport_playlist || !!beatportChartStates[identifier]; if (isBeatportChart) { if (socketConnected) socket.emit('discovery:unsubscribe', { ids: [identifier] }); delete _discoveryProgressCallbacks[identifier]; } }); Object.entries(_syncProgressCallbacks).forEach(([syncPlaylistId]) => { const beatportState = Object.values(youtubePlaylistStates).find(state => state && state.is_beatport_playlist && state.syncPlaylistId === syncPlaylistId ); if (beatportState) { if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); delete _syncProgressCallbacks[syncPlaylistId]; } }); } function resetBeatportSliderInitFlags() { const rebuildSlider = document.getElementById('beatport-rebuild-slider'); if (rebuildSlider) rebuildSlider.dataset.initialized = 'false'; const releasesSlider = document.getElementById('beatport-releases-slider'); if (releasesSlider) releasesSlider.dataset.initialized = 'false'; beatportReleasesSliderState.isInitialized = false; beatportHypePicksSliderState.isInitialized = false; const chartsSlider = document.getElementById('beatport-charts-slider'); if (chartsSlider) chartsSlider.dataset.initialized = 'false'; beatportChartsSliderState.isInitialized = false; const djSlider = document.getElementById('beatport-dj-slider'); if (djSlider) djSlider.dataset.initialized = 'false'; beatportDJSliderState.isInitialized = false; } function cleanupBeatportContent() { const wasLoaded = beatportContentState.loaded || !!beatportContentState.loadingPromise; if (!wasLoaded) return; console.log('๐Ÿงน Cleaning up Beatport content...'); if (beatportContentState.abortController) { beatportContentState.abortController.abort(); beatportContentState.abortController = null; } stopBeatportDiscoveryAndSyncPolling(); cleanupBeatportRebuildSlider(); cleanupBeatportReleasesSlider(); cleanupBeatportHypePicksSlider(); cleanupBeatportChartsSlider(); cleanupBeatportDJSlider(); resetBeatportSliderInitFlags(); beatportContentState.loadingPromise = null; beatportContentState.loaded = false; console.log('โœ… Beatport content cleaned up'); } // --- ListenBrainz Playlist State Management (Similar to YouTube/Tidal/Beatport) --- let listenbrainzPlaylistStates = {}; // Key: playlist_mbid, Value: playlist state with phases let listenbrainzPlaylistsLoaded = false; // Track if playlists have been loaded from backend // --- Artists Page State Management --- let artistsPageState = { currentView: 'search', // 'search', 'results', 'detail' searchQuery: '', searchResults: [], selectedArtist: null, sourceOverride: null, // Set when navigating from an alternate search tab 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 // --- Search Downloads Management State --- let searchDownloadBubbles = {}; // Track search download bubbles: artistName -> { artist, downloads: [] } let searchDownloadModalOpen = false; // Track if search download modal is open // --- Beatport Downloads Management State --- let beatportDownloadBubbles = {}; // Track Beatport download bubbles: chartKey -> { chart: { name, image }, downloads: [] } let beatportDownloadsUpdateTimeout = null; // Debounce Beatport downloads section updates let artistsSearchTimeout = null; let artistsSearchController = null; let artistCompletionController = null; // Track ongoing completion check to cancel when navigating away let similarArtistsController = null; // Track ongoing similar artists stream to cancel when navigating away // --- Lazy Background Image Observer --- // Watches elements with data-bg-src, applies background-image when visible, unobserves after. const lazyBgObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const el = entry.target; const src = el.dataset.bgSrc; if (src) { el.style.backgroundImage = `url('${src}')`; delete el.dataset.bgSrc; } lazyBgObserver.unobserve(el); } }); }, { rootMargin: '200px' }); /** * Observe all elements with data-bg-src within a container for lazy background loading. */ function observeLazyBackgrounds(container) { if (!container) return; const elements = container.querySelectorAll('[data-bg-src]'); elements.forEach(el => lazyBgObserver.observe(el)); } // =============================== // CONFIRM DIALOG (themed replacement for native confirm()) // =============================== let _confirmResolver = null; function showConfirmDialog({ title = 'Confirm', message = '', confirmText = 'Confirm', cancelText = 'Cancel', destructive = false } = {}) { // Resolve any pending dialog as cancelled before opening a new one if (_confirmResolver) { _confirmResolver(false); _confirmResolver = null; } const overlay = document.getElementById('confirm-modal-overlay'); const titleEl = document.getElementById('confirm-modal-title'); const messageEl = document.getElementById('confirm-modal-message'); const confirmBtn = document.getElementById('confirm-modal-confirm'); const cancelBtn = document.getElementById('confirm-modal-cancel'); titleEl.textContent = title; messageEl.textContent = message; confirmBtn.textContent = confirmText; cancelBtn.textContent = cancelText; // Toggle destructive (red) vs primary (accent) confirm button confirmBtn.className = destructive ? 'modal-button modal-button--cancel' : 'modal-button modal-button--primary'; overlay.classList.remove('hidden'); return new Promise(resolve => { _confirmResolver = resolve; }); } function resolveConfirmDialog(result) { const overlay = document.getElementById('confirm-modal-overlay'); overlay.classList.add('hidden'); if (_confirmResolver) { _confirmResolver(result); _confirmResolver = null; } } /** * Nuclear confirmation dialog for mass-destructive operations. * User must type an exact phrase to proceed. */ function showWitnessMeDialog(orphanCount) { return new Promise(resolve => { const overlay = document.createElement('div'); overlay.className = 'confirm-modal-overlay'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;'; overlay.innerHTML = `

Mass Deletion Warning

You are about to permanently delete ${orphanCount.toLocaleString()} files from your disk.

This many orphans usually means a path mismatch between your database and filesystem โ€” not actual orphan files. A previous user lost their entire library this way.

To confirm you understand the risk, type witness me below:

`; document.body.appendChild(overlay); const input = overlay.querySelector('#witness-me-input'); const confirmBtn = overlay.querySelector('#witness-confirm'); const cancelBtn = overlay.querySelector('#witness-cancel'); input.addEventListener('input', () => { const match = input.value.trim().toLowerCase() === 'witness me'; confirmBtn.disabled = !match; confirmBtn.style.background = match ? '#e74c3c' : '#555'; confirmBtn.style.color = match ? '#fff' : '#888'; confirmBtn.style.cursor = match ? 'pointer' : 'not-allowed'; }); confirmBtn.addEventListener('click', () => { document.body.removeChild(overlay); resolve(true); }); cancelBtn.addEventListener('click', () => { document.body.removeChild(overlay); resolve(false); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) { document.body.removeChild(overlay); resolve(false); } }); setTimeout(() => input.focus(), 100); }); } const MASS_ORPHAN_THRESHOLD = 20; function _isMassOrphanFix(jobId, count) { if (count <= MASS_ORPHAN_THRESHOLD) return false; // Only trigger if mass_orphan flag is actually set on visible findings // (flag is set by backend when >50% of files are orphans โ€” likely path mismatch) if (jobId === 'orphan_file_detector' || !jobId) { const massCards = document.querySelectorAll('.repair-finding-card[data-mass-orphan="true"]'); if (massCards.length > 0) return true; } return false; } // =============================== // WEBSOCKET CONNECTION MANAGER // =============================== let socket = null; let socketConnected = false; function initializeWebSocket() { if (typeof io === 'undefined') { console.warn('Socket.IO client not loaded โ€” falling back to HTTP polling'); return; } socket = io({ transports: ['polling', 'websocket'], reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 1000, reconnectionDelayMax: 10000, timeout: 20000 }); socket.on('connect', () => { console.log('WebSocket connected'); socketConnected = true; resubscribeDownloadBatches(); // Re-subscribe to any active sync/discovery rooms after reconnect const activeSyncIds = Object.keys(_syncProgressCallbacks); if (activeSyncIds.length > 0) { socket.emit('sync:subscribe', { playlist_ids: activeSyncIds }); console.log('๐Ÿ”„ Re-subscribed to sync rooms:', activeSyncIds); } const activeDiscoveryIds = Object.keys(_discoveryProgressCallbacks); if (activeDiscoveryIds.length > 0) { socket.emit('discovery:subscribe', { ids: activeDiscoveryIds }); console.log('๐Ÿ”„ Re-subscribed to discovery rooms:', activeDiscoveryIds); } // Join profile room for scoped watchlist/wishlist count updates if (currentProfile) { socket.emit('profile:join', { profile_id: currentProfile.id }); } }); socket.on('disconnect', (reason) => { console.warn('WebSocket disconnected:', reason); socketConnected = false; }); socket.on('reconnect', (attemptNumber) => { console.log(`WebSocket reconnected after ${attemptNumber} attempts`); // Rejoin profile room for scoped WebSocket emits if (currentProfile) { socket.emit('profile:join', { profile_id: currentProfile.id }); } // Phase 1: Full state refresh on reconnect fetchAndUpdateServiceStatus(); updateWatchlistButtonCount(); resubscribeDownloadBatches(); // Phase 2: Refresh dashboard data if on dashboard page if (currentPage === 'dashboard') { fetchAndUpdateSystemStats(); fetchAndUpdateActivityFeed(); fetchAndUpdateDbStats(); updateWishlistCount(); } }); // Phase 1 event listeners socket.on('status:update', handleServiceStatusUpdate); socket.on('watchlist:count', handleWatchlistCountUpdate); socket.on('downloads:batch_update', handleDownloadBatchUpdate); // Phase 2 event listeners (dashboard pollers) socket.on('rate-monitor:update', _handleRateMonitorUpdate); socket.on('dashboard:stats', handleDashboardStats); socket.on('dashboard:activity', handleDashboardActivity); socket.on('dashboard:toast', handleDashboardToast); socket.on('dashboard:db_stats', handleDashboardDbStats); socket.on('dashboard:wishlist_count', handleDashboardWishlistCount); // Phase 3 event listeners (enrichment sidebar workers) socket.on('enrichment:musicbrainz', (data) => updateMusicBrainzStatusFromData(data)); socket.on('enrichment:audiodb', (data) => updateAudioDBStatusFromData(data)); socket.on('enrichment:discogs', (data) => updateDiscogsStatusFromData(data)); socket.on('enrichment:deezer', (data) => updateDeezerStatusFromData(data)); socket.on('enrichment:spotify-enrichment', (data) => updateSpotifyEnrichmentStatusFromData(data)); socket.on('enrichment:itunes-enrichment', (data) => updateiTunesEnrichmentStatusFromData(data)); socket.on('enrichment:lastfm-enrichment', (data) => updateLastFMEnrichmentStatusFromData(data)); socket.on('enrichment:genius-enrichment', (data) => updateGeniusEnrichmentStatusFromData(data)); socket.on('enrichment:tidal-enrichment', (data) => updateTidalEnrichmentStatusFromData(data)); socket.on('enrichment:qobuz-enrichment', (data) => updateQobuzEnrichmentStatusFromData(data)); socket.on('enrichment:hydrabase', (data) => updateHydrabaseStatusFromData(data)); socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data)); socket.on('enrichment:soulid', (data) => updateSoulIDStatusFromData(data)); socket.on('enrichment:listening-stats', () => { }); // Status only, no UI update needed socket.on('repair:progress', (data) => updateRepairJobProgressFromData(data)); // Phase 4 event listeners (tool progress) socket.on('tool:stream', (data) => updateStreamStatusFromData(data)); socket.on('tool:quality-scanner', (data) => updateQualityScanProgressFromData(data)); socket.on('tool:duplicate-cleaner', (data) => updateDuplicateCleanProgressFromData(data)); socket.on('tool:retag', (data) => updateRetagStatusFromData(data)); socket.on('tool:db-update', (data) => updateDbProgressFromData(data)); socket.on('tool:metadata', (data) => updateMetadataStatusFromData(data)); socket.on('tool:logs', (data) => updateLogsFromData(data)); // Phase 5 event listeners (sync/discovery progress + scans) socket.on('sync:progress', (data) => updateSyncProgressFromData(data)); socket.on('discovery:progress', (data) => updateDiscoveryProgressFromData(data)); socket.on('scan:watchlist', (data) => updateWatchlistScanFromData(data)); socket.on('scan:media', (data) => updateMediaScanFromData(data)); socket.on('wishlist:stats', (data) => updateWishlistStatsFromData(data)); // Phase 6: Automation progress socket.on('automation:progress', (data) => updateAutomationProgressFromData(data)); } function handleServiceStatusUpdate(data) { // Cache for library status card _lastServiceStatus = data; // Same logic as fetchAndUpdateServiceStatus response handler updateServiceStatus('spotify', data.spotify); updateServiceStatus('media-server', data.media_server); updateServiceStatus('soulseek', data.soulseek); updateSidebarServiceStatus('spotify', data.spotify); updateSidebarServiceStatus('media-server', data.media_server); updateSidebarServiceStatus('soulseek', data.soulseek); // Update downloads nav badge from status push if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads); // Hide sync buttons (not the page) for standalone mode โ€” playlists still browsable/downloadable const isSoulsyncStandalone = data.media_server?.type === 'soulsync'; _isSoulsyncStandalone = isSoulsyncStandalone; document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => { if (isSoulsyncStandalone) { btn.dataset.hiddenByStandalone = '1'; btn.style.display = 'none'; } else if (btn.dataset.hiddenByStandalone) { delete btn.dataset.hiddenByStandalone; btn.style.display = ''; } // If not standalone and not previously hidden by standalone, leave display untouched // (preserves display:none on undiscovered LB/Last.fm playlist sync buttons) }); // Update enrichment service cards if (data.enrichment) renderEnrichmentCards(data.enrichment); // Spotify rate limit / cooldown / recovery if (data.spotify?.rate_limited && data.spotify.rate_limit) { handleSpotifyRateLimit(data.spotify.rate_limit); _spotifyInCooldown = false; } else if (data.spotify?.post_ban_cooldown > 0) { if (_spotifyRateLimitShown && !_spotifyInCooldown) { _spotifyRateLimitShown = false; _spotifyInCooldown = true; closeRateLimitModal(); showToast('Spotify ban expired \u2014 recovering shortly', 'info'); } } else { if (_spotifyInCooldown) { _spotifyInCooldown = false; showToast('Spotify access restored', 'success'); if (currentPage === 'discover') { loadDiscoverPage(); } } else if (_spotifyRateLimitShown) { handleSpotifyRateLimit(null); } } } function _updateHeroBtnCount(buttonId, badgeId, count) { const badge = document.getElementById(badgeId); if (badge) { badge.textContent = count; badge.classList.toggle('has-items', count > 0); } } function handleWatchlistCountUpdate(data) { if (data.success) { _updateHeroBtnCount('watchlist-button', 'watchlist-badge', data.count); // Update sidebar nav badge const wlNavBadge = document.getElementById('watchlist-nav-badge'); if (wlNavBadge) { wlNavBadge.textContent = data.count; wlNavBadge.classList.toggle('hidden', data.count === 0); } const watchlistButton = document.getElementById('watchlist-button'); if (watchlistButton) { const countdownText = data.next_run_in_seconds ? formatCountdownTime(data.next_run_in_seconds) : ''; if (countdownText) { watchlistButton.title = `Next auto-scan in ${countdownText}`; } } } } function handleDownloadBatchUpdate(payload) { const { batch_id, data } = payload; // Find which playlistId maps to this batch_id for (const [playlistId, process] of Object.entries(activeDownloadProcesses)) { if (process.batchId === batch_id) { processModalStatusUpdate(playlistId, data); break; } } } function resubscribeDownloadBatches() { if (!socket || !socketConnected) return; const activeBatchIds = []; Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => { if (process.batchId && (process.status === 'running' || process.status === 'complete')) { activeBatchIds.push(process.batchId); } }); if (activeBatchIds.length > 0) { socket.emit('downloads:subscribe', { batch_ids: activeBatchIds }); console.log(`WebSocket subscribed to ${activeBatchIds.length} download batches`); } } function subscribeToDownloadBatch(batchId) { if (socket && socketConnected && batchId) { socket.emit('downloads:subscribe', { batch_ids: [batchId] }); } } function unsubscribeFromDownloadBatch(batchId) { if (socket && socketConnected && batchId) { socket.emit('downloads:unsubscribe', { batch_ids: [batchId] }); } } // --- Phase 2: Dashboard event handlers --- function handleDashboardStats(data) { // Same logic as fetchAndUpdateSystemStats response handler updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading'); updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed this session'); updateStatCard('download-speed-card', data.download_speed, 'Combined speed'); updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing'); updateStatCard('uptime-card', data.uptime, 'Application runtime'); updateStatCard('memory-card', data.memory_usage, 'Current usage'); } function handleDashboardActivity(data) { // Same logic as fetchAndUpdateActivityFeed response handler updateActivityFeed(data.activities || []); } function handleDashboardToast(activity) { // Same logic as checkForActivityToasts response handler let toastType = 'info'; if (activity.icon === '\u2705' || activity.title.includes('Complete')) { toastType = 'success'; } else if (activity.icon === '\u274C' || activity.title.includes('Failed') || activity.title.includes('Error')) { toastType = 'error'; } else if (activity.icon === '\uD83D\uDEAB' || activity.title.includes('Cancelled')) { toastType = 'warning'; } showToast(`${activity.title}: ${activity.subtitle}`, toastType); } function handleDashboardDbStats(stats) { // Same logic as fetchAndUpdateDbStats response handler updateDashboardStatCards(stats); updateDbUpdaterCardInfo(stats); } function handleDashboardWishlistCount(data) { const count = data.count || 0; _updateHeroBtnCount('wishlist-button', 'wishlist-badge', count); // Update sidebar nav badge const wlNavBadge = document.getElementById('wishlist-nav-badge'); if (wlNavBadge) { wlNavBadge.textContent = count; wlNavBadge.classList.toggle('hidden', count === 0); } const wishlistButton = document.getElementById('wishlist-button'); if (wishlistButton) { if (count === 0) { wishlistButton.classList.remove('wishlist-active'); wishlistButton.classList.add('wishlist-inactive'); } else { wishlistButton.classList.remove('wishlist-inactive'); wishlistButton.classList.add('wishlist-active'); } } checkForAutoInitiatedWishlistProcess(); } // =============================== // END WEBSOCKET CONNECTION MANAGER // =============================== // --- Service Integration Logo Constants --- const MUSICBRAINZ_LOGO_URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png'; const DEEZER_LOGO_URL = 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610'; const SPOTIFY_LOGO_URL = 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png'; const ITUNES_LOGO_URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png'; const LASTFM_LOGO_URL = 'https://www.last.fm/static/images/lastfm_avatar_twitter.52a5d69a85ac.png'; const GENIUS_LOGO_URL = 'https://images.genius.com/8ed669cadd956443e29c70361ec4f372.1000x1000x1.png'; const TIDAL_LOGO_URL = 'https://www.svgrepo.com/show/519734/tidal.svg'; const QOBUZ_LOGO_URL = 'https://www.svgrepo.com/show/504778/qobuz.svg'; const DISCOGS_LOGO_URL = 'https://www.svgrepo.com/show/305957/discogs.svg'; function getAudioDBLogoURL() { const el = document.querySelector('img.audiodb-logo'); return el ? el.src : 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'); // Hide sidebar after completion hideSyncSidebar(); } 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'); // Hide sidebar after cancellation hideSyncSidebar(); } 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', testDashboardConnection: '/api/test-dashboard-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' } }; // Track last service status for library card (used by websocket handler in core + artists) let _lastServiceStatus = null; let _isSoulsyncStandalone = false; // Global flag: true when no media server (sync buttons hidden) // ===============================