soulsync/webui/static/core.js
BoulderBadgeDad 07d09d7d0e Stream button: the player never learns its stream is ready (2.6.5 regression)
Since the per-listener stream sessions refactor (Phase 3b), every browser gets
its own stream session — but the 1s 'tool:stream' socket broadcast still read
the legacy GLOBAL state (the DEFAULT session no real browser uses), so it told
every client "stopped" forever. The frontend skipped HTTP polling whenever the
WebSocket was up, so it only ever saw that wrong broadcast: the backend prep
downloaded the track, moved it into the session's stream folder and sat at
"ready" while the mini player showed nothing. Proxy users whose WebSockets
don't connect fell back to HTTP polling (session-correct) and streamed fine —
which is why this hid so well.

Fix: stream status is inherently per-listener, so stop pretending a global
broadcast can carry it —
- web_server.py: remove the 'tool:stream' emit from the tool-progress loop
  (the broadcast thread has no request context; it can only ever see DEFAULT)
- media-player.js: the status poller always polls /api/stream/status (resolves
  the caller's own session from the cookie); drop the dead broadcast handler
- core.js: unwire the 'tool:stream' socket listener

Observability fix that made this undebuggable: core/streaming/prepare.py used
getLogger(__name__) — outside the soulsync.* namespace where handlers attach —
so every prep log line (including failures) vanished from app.log. Moved to
get_logger("streaming.prepare") + a regression test locking the namespace.

34 streaming tests pass; ruff clean; web_server compiles; JS syntax-checked.
2026-06-06 12:43:20 -07:00

935 lines
38 KiB
JavaScript

// SoulSync WebUI JavaScript - Replicating PyQt6 GUI Functionality
const PAGE_WILL_CHANGE_EVENT = 'ss:webui-page-will-change';
// 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 playlistTrackSnapshotCache = {}; // Key: playlist_id, Value: upstream snapshot_id at cache time
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;
// --- Qobuz Playlist State Management (mirrors Tidal — github issue #677) ---
let qobuzPlaylists = [];
let qobuzPlaylistStates = {}; // Key: playlist_id, Value: playlist state with phases
let qobuzPlaylistsLoaded = 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
function cancelSimilarArtistsLoad() {
if (similarArtistsController) {
similarArtistsController.abort();
similarArtistsController = null;
}
}
// --- 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 = `
<div style="background:var(--bg-secondary, #1e1e2e);border:2px solid #e74c3c;border-radius:12px;padding:28px;max-width:480px;width:90%;color:var(--text-primary, #fff);font-family:inherit;">
<h3 style="margin:0 0 8px;color:#e74c3c;font-size:1.2em;">Mass Deletion Warning</h3>
<p style="margin:0 0 12px;font-size:0.95em;opacity:0.9;">
You are about to <strong>permanently delete ${orphanCount.toLocaleString()} files</strong> from your disk.
</p>
<p style="margin:0 0 12px;font-size:0.9em;opacity:0.75;">
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.
</p>
<p style="margin:0 0 6px;font-size:0.9em;opacity:0.9;">
To confirm you understand the risk, type <strong style="color:#e74c3c;">witness me</strong> below:
</p>
<input type="text" id="witness-me-input" autocomplete="off" spellcheck="false"
placeholder="Type the phrase here..."
style="width:100%;padding:10px;border:1px solid #555;border-radius:6px;background:var(--bg-primary, #111);color:var(--text-primary, #fff);font-size:1em;margin:8px 0 16px;box-sizing:border-box;">
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button id="witness-cancel" style="padding:8px 20px;border:1px solid #555;border-radius:6px;background:transparent;color:var(--text-primary, #fff);cursor:pointer;font-size:0.9em;">
Cancel
</button>
<button id="witness-confirm" disabled
style="padding:8px 20px;border:none;border-radius:6px;background:#555;color:#888;cursor:not-allowed;font-size:0.9em;font-weight:600;transition:all 0.2s;">
Delete Files
</button>
</div>
</div>
`;
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({
// Polling-first (Socket.IO default) then upgrade — most compatible behind
// reverse proxies that don't cleanly forward WebSocket upgrade headers
// (common in self-hosted setups). websocket-first shaves connect time when
// it works but silently breaks real-time updates where the proxy blocks WS.
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:amazon-enrichment', (data) => updateAmazonEnrichmentStatusFromData(data));
socket.on('enrichment:similar_artists', (data) => updateSimilarArtistsEnrichmentStatusFromData(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));
// Forward enrichment status to the dashboard worker-orbs so the hub fires
// a pulse on each real item matched / error (additional listener — does not
// disturb the UI handlers above).
['musicbrainz', 'audiodb', 'discogs', 'deezer', 'spotify-enrichment',
'itunes-enrichment', 'lastfm-enrichment', 'genius-enrichment', 'tidal-enrichment',
'qobuz-enrichment', 'amazon-enrichment', 'similar_artists', 'hydrabase',
'soulid', 'repair'].forEach((ch) => {
socket.on('enrichment:' + ch, (data) => {
if (window.workerOrbs && window.workerOrbs.onStatus) window.workerOrbs.onStatus(ch, data);
});
});
// Phase 4 event listeners (tool progress)
// 'tool:stream' is intentionally NOT wired: stream state is per-listener
// (session cookie), so the global broadcast could only carry the DEFAULT
// session's eternal "stopped" — the player polls /api/stream/status instead.
socket.on('tool:quality-scanner', (data) => updateQualityScanProgressFromData(data));
socket.on('tool:duplicate-cleaner', (data) => updateDuplicateCleanProgressFromData(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);
const watchlistBtn = document.querySelector('.nav-button[data-page="watchlist"]');
if (watchlistBtn) {
watchlistBtn.classList.toggle('nav-watchlist-scanning', data.status === 'scanning');
}
});
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
_lastStatusPayload = data;
if (typeof syncSpotifySettingsAuthState === 'function') {
syncSpotifySettingsAuthState(data?.spotify || null);
}
if (typeof syncPrimaryMetadataSourceAvailability === 'function') {
syncPrimaryMetadataSourceAvailability(data?.spotify || null);
}
if (typeof sanitizeMetadataSourceSelection === 'function') {
sanitizeMetadataSourceSelection({ quiet: true });
}
// Same logic as fetchAndUpdateServiceStatus response handler
updateServiceStatus('metadata-source', data.metadata_source, data.spotify);
updateServiceStatus('media-server', data.media_server);
updateServiceStatus('soulseek', data.soulseek);
updateSidebarServiceStatus('metadata-source', data.metadata_source, 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 (btn.id === 'stats-sync-btn') return; // React stats page owns this control now.
if (btn.classList.contains('soulsync-standalone-action')) return;
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 downloads');
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';
const AMAZON_LOGO_URL = '/static/amazon.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 the last `/status` payload (shared service snapshot used across the UI)
let _lastStatusPayload = null;
let _isSoulsyncStandalone = false; // Global flag: true when no media server (sync buttons hidden)
function getActiveMetadataSource() {
return _lastStatusPayload?.metadata_source?.source || 'spotify';
}
// ===============================