Split monolithic script.js (78K lines) into 17 domain modules

Extracts the single 77,957-line script.js into focused modules:

  core.js            (874)   - Global state, confirm dialog, websocket, constants
  init.js            (2358)  - Initialization, personal settings, navigation
  media-player.js    (2398)  - Media player, audio, visualizer, radio
  settings.js        (3657)  - Settings page, quality profiles, API keys, auth
  search.js          (1542)  - Search functionality, page data loading
  sync-spotify.js    (2538)  - Spotify sync, YouTube backend, hero section
  downloads.js       (6398)  - Wing It, batched polling, cancel, notifications
  wishlist-tools.js  (7234)  - Wishlist, matched downloads, tools, retag
  sync-services.js   (9076)  - Tidal, Deezer, Beatport, YouTube, ListenBrainz sync
  artists.js         (4610)  - Artists page, artist downloads
  api-monitor.js     (3798)  - API rate monitor gauges
  library.js         (6652)  - Library, artist detail, enhanced management
  beatport-ui.js     (3902)  - Beatport sliders, genre browser
  discover.js        (8920)  - Discover page and all sub-sections
  enrichment.js      (3551)  - All enrichment workers, library repair
  stats-automations.js (7575) - Stats, automations, issues, import
  pages-extra.js     (2874)  - Playlist explorer, server playlists, active downloads

Load order: core.js first (globals), init.js last (DOMContentLoaded).
All other modules define functions and load in any order.
No functional changes - pure extraction along existing section boundaries.
pull/352/head
JohnBaumb 1 month ago
parent 47ced912b9
commit a66c4d06e1

@ -8031,7 +8031,24 @@
<script src="{{ url_for('static', filename='vendor/socket.io.min.js') }}"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="{{ url_for('static', filename='setup-wizard.js') }}"></script>
<script src="{{ url_for('static', filename='script.js') }}"></script>
<!-- Split modules (was: script.js) — core.js must load first, init.js last -->
<script src="{{ url_for('static', filename='core.js') }}"></script>
<script src="{{ url_for('static', filename='media-player.js') }}"></script>
<script src="{{ url_for('static', filename='settings.js') }}"></script>
<script src="{{ url_for('static', filename='search.js') }}"></script>
<script src="{{ url_for('static', filename='sync-spotify.js') }}"></script>
<script src="{{ url_for('static', filename='downloads.js') }}"></script>
<script src="{{ url_for('static', filename='wishlist-tools.js') }}"></script>
<script src="{{ url_for('static', filename='sync-services.js') }}"></script>
<script src="{{ url_for('static', filename='artists.js') }}"></script>
<script src="{{ url_for('static', filename='api-monitor.js') }}"></script>
<script src="{{ url_for('static', filename='library.js') }}"></script>
<script src="{{ url_for('static', filename='beatport-ui.js') }}"></script>
<script src="{{ url_for('static', filename='discover.js') }}"></script>
<script src="{{ url_for('static', filename='enrichment.js') }}"></script>
<script src="{{ url_for('static', filename='stats-automations.js') }}"></script>
<script src="{{ url_for('static', filename='pages-extra.js') }}"></script>
<script src="{{ url_for('static', filename='init.js') }}"></script>
<!-- Notification bell + floating helper toggle — always accessible above modals -->
<!-- Global Search Bar — Spotlight-style search from anywhere -->
<div class="gsearch-bar" id="gsearch-bar">

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -0,0 +1,879 @@
// 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 = `
<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({
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)
// ===============================

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save