// 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
// --- 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 => {
btn.style.display = isSoulsyncStandalone ? 'none' : '';
});
// 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'
}
};
// ===============================
// INITIALIZATION
// ===============================
// ---- Accent Color System ----
function getAccentFallbackColors() {
let accent = localStorage.getItem('soulsync-accent') || '#1db954';
if (!/^#[0-9a-fA-F]{6}$/.test(accent)) accent = '#1db954';
// Compute a lighter variant for the second color
const r = parseInt(accent.slice(1, 3), 16), g = parseInt(accent.slice(3, 5), 16), b = parseInt(accent.slice(5, 7), 16);
const lighter = '#' + [Math.min(r + 20, 255), Math.min(g + 30, 255), Math.min(b + 12, 255)]
.map(v => v.toString(16).padStart(2, '0')).join('');
return [accent, lighter];
}
function applyAccentColor(hex) {
// Validate hex format โ reject corrupt values
if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(hex)) {
hex = '#1db954'; // fallback to default
}
// Convert hex to RGB
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
// Convert RGB to HSL
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
let h = 0, s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
else h = ((rn - gn) / d + 4) / 6;
}
// Compute light variant: +16% lightness
const lightL = Math.min(l + 0.16, 0.95);
// Compute neon variant: high lightness + boosted saturation
const neonL = Math.min(l + 0.30, 0.95);
const neonS = Math.min(s + 0.1, 1.0);
function hslToRgb(h, s, l) {
if (s === 0) { const v = Math.round(l * 255); return [v, v, v]; }
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return [Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
Math.round(hue2rgb(p, q, h) * 255),
Math.round(hue2rgb(p, q, h - 1 / 3) * 255)];
}
const light = hslToRgb(h, s, lightL);
const neon = hslToRgb(h, neonS, neonL);
const root = document.documentElement.style;
root.setProperty('--accent-rgb', `${r}, ${g}, ${b}`);
root.setProperty('--accent-light-rgb', `${light[0]}, ${light[1]}, ${light[2]}`);
root.setProperty('--accent-neon-rgb', `${neon[0]}, ${neon[1]}, ${neon[2]}`);
// Store for instant restore on next page load
localStorage.setItem('soulsync-accent', hex);
// Update preview swatch if it exists
const swatch = document.getElementById('accent-preview-swatch');
if (swatch) swatch.style.background = hex;
}
function applyParticlesSetting(enabled) {
const canvas = document.getElementById('page-particles-canvas');
if (canvas) canvas.style.display = enabled ? '' : 'none';
if (window.pageParticles) {
if (enabled) {
const activePage = document.querySelector('.page.active');
if (activePage) {
window.pageParticles.setPage(activePage.id.replace('-page', ''));
}
} else {
window.pageParticles.stop();
}
}
window._particlesEnabled = enabled;
localStorage.setItem('soulsync-particles', String(enabled));
}
function applyWorkerOrbsSetting(enabled) {
window._workerOrbsEnabled = enabled;
localStorage.setItem('soulsync-worker-orbs', String(enabled));
if (window.workerOrbs) {
if (enabled) {
const activePage = document.querySelector('.page.active');
if (activePage && activePage.id === 'dashboard-page') {
window.workerOrbs.setPage('dashboard');
}
} else {
window.workerOrbs.setPage('_disabled');
}
}
}
function initAccentColorListeners() {
const presetSelect = document.getElementById('accent-preset');
const customGroup = document.getElementById('custom-color-group');
const customPicker = document.getElementById('accent-custom-color');
if (!presetSelect) return;
presetSelect.addEventListener('change', () => {
const val = presetSelect.value;
if (val === 'custom') {
if (customGroup) customGroup.style.display = '';
if (customPicker) applyAccentColor(customPicker.value);
} else {
if (customGroup) customGroup.style.display = 'none';
applyAccentColor(val);
}
});
if (customPicker) {
customPicker.addEventListener('input', () => {
applyAccentColor(customPicker.value);
});
}
// Particles toggle โ apply immediately on change
const particlesCheckbox = document.getElementById('particles-enabled');
if (particlesCheckbox) {
particlesCheckbox.addEventListener('change', () => {
applyParticlesSetting(particlesCheckbox.checked);
});
}
// Worker orbs toggle โ apply immediately on change
const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled');
if (workerOrbsCheckbox) {
workerOrbsCheckbox.addEventListener('change', () => {
applyWorkerOrbsSetting(workerOrbsCheckbox.checked);
});
}
// Reduce effects toggle โ apply immediately on change
const reduceEffectsCheckbox = document.getElementById('reduce-effects-enabled');
if (reduceEffectsCheckbox) {
reduceEffectsCheckbox.addEventListener('change', () => {
applyReduceEffects(reduceEffectsCheckbox.checked);
});
}
}
function applyReduceEffects(enabled) {
if (enabled) {
document.body.classList.add('reduce-effects');
} else {
document.body.classList.remove('reduce-effects');
}
localStorage.setItem('soulsync-reduce-effects', enabled ? '1' : '0');
}
// Bootstrap accent and reduce-effects from localStorage instantly (prevents flash)
(function () {
if (localStorage.getItem('soulsync-reduce-effects') === '1') {
document.body.classList.add('reduce-effects');
}
const saved = localStorage.getItem('soulsync-accent');
if (saved) applyAccentColor(saved);
// Bootstrap particles setting from localStorage
const particlesSaved = localStorage.getItem('soulsync-particles');
if (particlesSaved === 'false') {
window._particlesEnabled = false;
const canvas = document.getElementById('page-particles-canvas');
if (canvas) canvas.style.display = 'none';
}
// Bootstrap worker orbs setting from localStorage
const workerOrbsSaved = localStorage.getItem('soulsync-worker-orbs');
if (workerOrbsSaved === 'false') {
window._workerOrbsEnabled = false;
}
})();
// โโ Profile System โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let currentProfile = null;
function getProfileHomePage() {
if (!currentProfile) return 'dashboard';
if (currentProfile.home_page) return currentProfile.home_page;
return currentProfile.is_admin ? 'dashboard' : 'discover';
}
function isPageAllowed(pageId) {
if (!currentProfile) return true;
if (currentProfile.id === 1) return true;
if (pageId === 'help' || pageId === 'issues') return true;
if (pageId === 'artist-detail') {
// artist-detail requires library access
const ap = currentProfile.allowed_pages;
if (!ap) return true;
return ap.includes('library');
}
if (pageId === 'settings') return currentProfile.is_admin;
const ap = currentProfile.allowed_pages;
if (!ap) return true; // null = all pages
return ap.includes(pageId);
}
function canDownload() {
if (!currentProfile) return true;
if (currentProfile.id === 1) return true;
return currentProfile.can_download !== false && currentProfile.can_download !== 0;
}
function renderProfileAvatar(el, profile) {
// Renders avatar as image (if avatar_url set) or colored initial fallback
// Preserves existing classes, ensures 'profile-avatar' is present
if (!el.classList.contains('profile-avatar') && !el.classList.contains('profile-indicator-avatar') && !el.classList.contains('profile-pin-avatar')) {
el.className = 'profile-avatar';
}
el.style.background = profile.avatar_color || '#6366f1';
el.textContent = '';
if (profile.avatar_url) {
const img = document.createElement('img');
img.src = profile.avatar_url;
img.alt = profile.name;
img.className = 'profile-avatar-img';
img.onerror = () => {
img.remove();
el.textContent = profile.name.charAt(0).toUpperCase();
};
el.appendChild(img);
} else {
el.textContent = profile.name.charAt(0).toUpperCase();
}
}
async function initProfileSystem() {
try {
// Check if a session already has a profile selected
const currentRes = await fetch('/api/profiles/current');
const currentData = await currentRes.json();
if (currentData.success && currentData.profile) {
currentProfile = currentData.profile;
updateProfileIndicator();
// Check if launch PIN is required
if (currentData.launch_pin_required) {
showLaunchPinScreen();
return false; // Defer app init until PIN verified
}
return true; // Profile already selected, skip picker
}
// Fetch all profiles
const res = await fetch('/api/profiles');
const data = await res.json();
const profiles = data.profiles || [];
if (profiles.length === 0) {
// No profiles yet โ auto-select admin profile 1
await selectProfile(1);
return true;
}
if (profiles.length === 1) {
// Only one profile โ always auto-select (PIN only matters with multiple profiles)
await selectProfile(profiles[0].id);
// Re-check for launch PIN after auto-select
const recheck = await fetch('/api/profiles/current');
const recheckData = await recheck.json();
if (recheckData.launch_pin_required) {
showLaunchPinScreen();
return false;
}
return true;
}
// Multiple profiles or PIN required โ show picker
showProfilePicker(profiles);
return false; // App init deferred until profile selected
} catch (e) {
console.error('Profile init error:', e);
return true; // Fall through to normal init
}
}
// โโ Launch PIN Lock Screen โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function showLaunchPinScreen() {
const overlay = document.getElementById('launch-pin-overlay');
if (!overlay) return;
overlay.style.display = 'flex';
const input = document.getElementById('launch-pin-input');
const submit = document.getElementById('launch-pin-submit');
const error = document.getElementById('launch-pin-error');
input.value = '';
error.style.display = 'none';
setTimeout(() => input.focus(), 100);
const doSubmit = async () => {
const pin = input.value.trim();
if (!pin) return;
submit.disabled = true;
submit.textContent = 'Verifying...';
try {
const res = await fetch('/api/profiles/verify-launch-pin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin })
});
const data = await res.json();
if (data.success) {
// Server session flag set by verify endpoint โ consumed on next /api/profiles/current call
overlay.style.display = 'none';
initApp(); // Now safe to load the full app
} else {
error.textContent = data.error || 'Invalid PIN';
error.style.display = 'block';
input.value = '';
input.focus();
// Shake animation
overlay.querySelector('.launch-pin-container').classList.add('shake');
setTimeout(() => overlay.querySelector('.launch-pin-container').classList.remove('shake'), 500);
}
} catch (e) {
error.textContent = 'Connection error';
error.style.display = 'block';
}
submit.disabled = false;
submit.textContent = 'Unlock';
};
// Remove old listeners to prevent stacking
const newSubmit = submit.cloneNode(true);
submit.parentNode.replaceChild(newSubmit, submit);
newSubmit.addEventListener('click', doSubmit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doSubmit();
});
}
// โโ Security Settings Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function saveSecurityPin() {
const pin = document.getElementById('security-new-pin').value;
const confirm = document.getElementById('security-confirm-pin').value;
const msg = document.getElementById('security-pin-msg');
if (!pin || pin.length < 4) {
msg.textContent = 'PIN must be at least 4 characters';
msg.style.display = 'block';
msg.style.color = '#ff5252';
return;
}
if (pin !== confirm) {
msg.textContent = 'PINs do not match';
msg.style.display = 'block';
msg.style.color = '#ff5252';
return;
}
try {
const res = await fetch('/api/profiles/1/set-pin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin })
});
const data = await res.json();
if (data.success) {
msg.textContent = 'PIN saved! You can now enable the lock screen.';
msg.style.color = '#4caf50';
msg.style.display = 'block';
// Update UI โ hide setup, show change, enable toggle
document.getElementById('security-pin-setup').style.display = 'none';
document.getElementById('security-change-pin-section').style.display = 'block';
document.getElementById('security-require-pin').disabled = false;
// Clear inputs
document.getElementById('security-new-pin').value = '';
document.getElementById('security-confirm-pin').value = '';
} else {
msg.textContent = data.error || 'Failed to save PIN';
msg.style.color = '#ff5252';
msg.style.display = 'block';
}
} catch (e) {
msg.textContent = 'Connection error';
msg.style.color = '#ff5252';
msg.style.display = 'block';
}
}
function handleSecurityPinToggle(checkbox) {
// If trying to enable but no PIN, show the setup section
if (checkbox.checked) {
const setupSection = document.getElementById('security-pin-setup');
if (setupSection.style.display !== 'none' || checkbox.disabled) {
checkbox.checked = false;
setupSection.style.display = 'block';
document.getElementById('security-new-pin').focus();
return;
}
}
// Auto-save this setting
saveSettings(true);
}
function showChangeSecurityPin() {
document.getElementById('security-pin-setup').style.display = 'block';
document.getElementById('security-new-pin').focus();
}
// โโ Forgot PIN Recovery โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function showForgotPinView() {
document.getElementById('launch-pin-entry').style.display = 'none';
document.getElementById('launch-pin-recovery').style.display = 'block';
document.getElementById('launch-recovery-input').value = '';
document.getElementById('launch-recovery-error').style.display = 'none';
setTimeout(() => document.getElementById('launch-recovery-input').focus(), 100);
}
function showPinEntryView() {
document.getElementById('launch-pin-recovery').style.display = 'none';
document.getElementById('launch-pin-entry').style.display = 'block';
setTimeout(() => document.getElementById('launch-pin-input').focus(), 100);
}
async function submitRecoveryCredential() {
const input = document.getElementById('launch-recovery-input');
const error = document.getElementById('launch-recovery-error');
const btn = document.getElementById('launch-recovery-submit');
const credential = input.value.trim();
if (!credential) return;
btn.disabled = true;
btn.textContent = 'Verifying...';
error.style.display = 'none';
try {
const res = await fetch('/api/profiles/reset-pin-via-credential', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential })
});
const data = await res.json();
if (data.success) {
sessionStorage.setItem('soulsync_pin_ok', '1');
document.getElementById('launch-pin-overlay').style.display = 'none';
initApp();
setTimeout(() => showToast('PIN cleared. You can set a new one in Settings โ Advanced.', 'success'), 1000);
} else {
error.textContent = data.error || 'Credential not recognized';
error.style.display = 'block';
input.value = '';
input.focus();
document.getElementById('launch-pin-container').classList.add('shake');
setTimeout(() => document.getElementById('launch-pin-container').classList.remove('shake'), 500);
}
} catch (e) {
error.textContent = 'Connection error';
error.style.display = 'block';
}
btn.disabled = false;
btn.textContent = 'Verify & Reset PIN';
}
// โโ Profile PIN Forgot Recovery โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function showProfileForgotPin() {
const dialog = document.getElementById('profile-pin-dialog');
const content = dialog.querySelector('.profile-pin-content');
// Store the profile ID we're recovering for
const profileName = document.getElementById('profile-pin-name').textContent;
// Replace dialog content with recovery form
content.dataset.prevHtml = content.innerHTML;
content.innerHTML = `
Reset PIN for ${profileName}
Enter any configured API credential (Spotify secret, Plex token, etc.)
${k.key_prefix || 'sk_...'}...
· Created ${k.created_at ? new Date(k.created_at).toLocaleDateString() : 'unknown'}
${k.last_used_at ? '· Last used ' + new Date(k.last_used_at).toLocaleDateString() : ''}
`).join('');
}
async function generateApiKey() {
const labelInput = document.getElementById('api-key-label');
const label = labelInput ? labelInput.value.trim() : '';
try {
const response = await fetch('/api/v1/api-keys-internal/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: label || 'Default' })
});
const data = await response.json();
if (data.success && data.data?.key) {
const keyDisplay = document.getElementById('api-key-generated');
const keyValue = document.getElementById('api-key-value');
if (keyDisplay && keyValue) {
keyValue.textContent = data.data.key;
keyDisplay.style.display = 'block';
}
if (labelInput) labelInput.value = '';
showToast('API key generated! Copy it now.', 'success');
loadApiKeys();
} else {
showToast(data.error?.message || 'Failed to generate API key', 'error');
}
} catch (error) {
console.error('Error generating API key:', error);
showToast('Failed to generate API key', 'error');
}
}
function copyApiKey() {
const keyValue = document.getElementById('api-key-value');
if (keyValue) {
navigator.clipboard.writeText(keyValue.textContent).then(() => {
showToast('API key copied to clipboard', 'success');
}).catch(() => {
// Fallback for older browsers
const range = document.createRange();
range.selectNode(keyValue);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
document.execCommand('copy');
showToast('API key copied', 'success');
});
}
}
async function revokeApiKey(keyId, label) {
if (!await showConfirmDialog({ title: 'Revoke API Key', message: `Revoke API key "${label}"? Any apps using this key will stop working.`, confirmText: 'Revoke', destructive: true })) return;
try {
const response = await fetch(`/api/v1/api-keys-internal/revoke/${keyId}`, { method: 'DELETE' });
const data = await response.json();
if (data.success) {
showToast('API key revoked', 'success');
loadApiKeys();
} else {
showToast(data.error?.message || 'Failed to revoke key', 'error');
}
} catch (error) {
console.error('Error revoking API key:', error);
showToast('Failed to revoke key', 'error');
}
}
// Dashboard-specific test functions that create activity items
async function testDashboardConnection(service) {
try {
showLoadingOverlay(`Testing ${service} service...`);
const response = await fetch(API.testDashboardConnection, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service })
});
const result = await response.json();
if (result.success) {
// Use backend's message which contains dynamic source name
showToast(result.message || `${service} service verified`, 'success');
// Refresh status indicators immediately so UI reflects the new state
fetchAndUpdateServiceStatus();
} else {
showToast(`${service} service check failed: ${result.error}`, 'error');
}
} catch (error) {
console.error(`Error testing ${service} service:`, error);
showToast(`Failed to test ${service} service`, 'error');
} finally {
hideLoadingOverlay();
}
}
// Individual Auto-detect functions - same as GUI
async function autoDetectPlex() {
try {
showLoadingOverlay('Auto-detecting Plex server...');
const response = await fetch('/api/detect-media-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_type: 'plex' })
});
const result = await response.json();
if (result.success) {
document.getElementById('plex-url').value = result.found_url;
showToast(`Plex server detected: ${result.found_url}`, 'success');
} else {
showToast(result.error, 'error');
}
} catch (error) {
console.error('Error auto-detecting Plex:', error);
showToast('Failed to auto-detect Plex server', 'error');
} finally {
hideLoadingOverlay();
}
}
async function autoDetectJellyfin() {
try {
showLoadingOverlay('Auto-detecting Jellyfin server...');
const response = await fetch('/api/detect-media-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_type: 'jellyfin' })
});
const result = await response.json();
if (result.success) {
document.getElementById('jellyfin-url').value = result.found_url;
showToast(`Jellyfin server detected: ${result.found_url}`, 'success');
} else {
showToast(result.error, 'error');
}
} catch (error) {
console.error('Error auto-detecting Jellyfin:', error);
showToast('Failed to auto-detect Jellyfin server', 'error');
} finally {
hideLoadingOverlay();
}
}
async function autoDetectNavidrome() {
try {
showLoadingOverlay('Auto-detecting Navidrome server...');
const response = await fetch('/api/detect-media-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_type: 'navidrome' })
});
const result = await response.json();
if (result.success) {
document.getElementById('navidrome-url').value = result.found_url;
showToast(`Navidrome server detected: ${result.found_url}`, 'success');
} else {
showToast(result.error, 'error');
}
} catch (error) {
console.error('Error auto-detecting Navidrome:', error);
showToast('Failed to auto-detect Navidrome server', 'error');
} finally {
hideLoadingOverlay();
}
}
async function autoDetectSlskd() {
try {
showLoadingOverlay('Auto-detecting Soulseek (slskd) server...');
const response = await fetch('/api/detect-soulseek', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (result.success) {
document.getElementById('soulseek-url').value = result.found_url;
showToast(`Soulseek server detected: ${result.found_url}`, 'success');
} else {
showToast(result.error, 'error');
}
} catch (error) {
console.error('Error auto-detecting Soulseek:', error);
showToast('Failed to auto-detect Soulseek server', 'error');
} finally {
hideLoadingOverlay();
}
}
function cancelDetection(service) {
const progressDiv = document.getElementById(`${service}-detection-progress`);
progressDiv.classList.add('hidden');
showToast(`${service} detection cancelled`, 'error');
}
function updateStatusDisplays() {
// Update status displays based on current service status
// This would be called after status updates
const services = ['spotify', 'media-server', 'soulseek'];
services.forEach(service => {
const display = document.getElementById(`${service}-status-display`);
if (display) {
// Status will be updated by the regular status monitoring
}
});
}
async function authenticateSpotify() {
try {
showLoadingOverlay('Saving credentials and starting Spotify authentication...');
// Save settings first to ensure client_id/client_secret are persisted
await saveSettings();
showToast('Spotify authentication started', 'success');
window.open('/auth/spotify', '_blank');
} catch (error) {
console.error('Error authenticating Spotify:', error);
showToast('Failed to start Spotify authentication', 'error', 'gs-connecting');
} finally {
hideLoadingOverlay();
}
}
async function disconnectSpotify() {
const fallbackName = currentMusicSourceName !== 'Spotify' ? currentMusicSourceName : 'the configured fallback source';
if (!await showConfirmDialog({ title: 'Disconnect Spotify', message: `Disconnect Spotify? The app will switch to ${fallbackName} for metadata.` })) {
return;
}
try {
showLoadingOverlay('Disconnecting Spotify...');
const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
const data = await response.json();
if (data.success) {
showToast(`Spotify disconnected. Now using ${fallbackName}.`, 'success');
// Immediately refresh status to update UI
await fetchAndUpdateServiceStatus();
} else {
showToast(`Failed to disconnect: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error disconnecting Spotify:', error);
showToast('Failed to disconnect Spotify', 'error');
} finally {
hideLoadingOverlay();
}
}
async function clearSpotifyCacheAndFallback() {
const fallbackName = currentMusicSourceName !== 'Spotify' ? currentMusicSourceName : 'the configured fallback source';
if (!await showConfirmDialog({
title: 'Clear Spotify Cache',
message: `This will clear the Spotify token cache and switch metadata to ${fallbackName}. You can re-authenticate later.`
})) return;
try {
showLoadingOverlay('Clearing Spotify cache...');
const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
const data = await response.json();
if (data.success) {
showToast(data.message || `Switched to ${fallbackName}`, 'success');
await fetchAndUpdateServiceStatus();
} else {
showToast(`Failed: ${data.error}`, 'error');
}
} catch (error) {
showToast('Failed to clear Spotify cache', 'error');
} finally {
hideLoadingOverlay();
}
}
// โโ Spotify Rate Limit Handling โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let _spotifyRateLimitShown = false;
let _spotifyInCooldown = false;
let _rateLimitModalOpen = false;
let _rateLimitCountdownInterval = null;
let _rateLimitExpiresAt = 0;
function handleSpotifyRateLimit(rateLimitInfo) {
if (!rateLimitInfo || !rateLimitInfo.active) {
if (_spotifyRateLimitShown) {
_spotifyRateLimitShown = false;
closeRateLimitModal();
showToast('Spotify access restored', 'success');
// Refresh discover page if user is on it โ data source switched back to Spotify
if (currentPage === 'discover') {
console.log('Spotify restored โ refreshing discover page data');
loadDiscoverPage();
}
}
return;
}
// Update countdown if modal is open (status pushes every 10s keep it accurate)
if (_rateLimitModalOpen && rateLimitInfo.remaining_seconds) {
_rateLimitExpiresAt = Date.now() + (rateLimitInfo.remaining_seconds * 1000);
}
if (!_spotifyRateLimitShown) {
_spotifyRateLimitShown = true;
_spotifyInCooldown = false;
showRateLimitModal(rateLimitInfo);
// Refresh discover page if user is on it โ data source switched to iTunes
if (currentPage === 'discover') {
console.log('Spotify rate limited โ refreshing discover page with iTunes data');
loadDiscoverPage();
}
}
}
function showRateLimitModal(rateLimitInfo) {
const overlay = document.getElementById('rate-limit-modal-overlay');
if (!overlay) return;
// Populate details
const banDuration = document.getElementById('rate-limit-ban-duration');
const endpoint = document.getElementById('rate-limit-endpoint');
const countdown = document.getElementById('rate-limit-countdown');
banDuration.textContent = formatRateLimitDuration(rateLimitInfo.retry_after || rateLimitInfo.remaining_seconds);
endpoint.textContent = rateLimitInfo.endpoint || 'unknown';
countdown.textContent = formatRateLimitDuration(rateLimitInfo.remaining_seconds);
// Set expiry for live countdown
_rateLimitExpiresAt = Date.now() + (rateLimitInfo.remaining_seconds * 1000);
// Start live countdown timer
if (_rateLimitCountdownInterval) clearInterval(_rateLimitCountdownInterval);
_rateLimitCountdownInterval = setInterval(() => {
const remaining = Math.max(0, Math.round((_rateLimitExpiresAt - Date.now()) / 1000));
countdown.textContent = formatRateLimitDuration(remaining);
if (remaining <= 0) {
clearInterval(_rateLimitCountdownInterval);
_rateLimitCountdownInterval = null;
}
}, 1000);
overlay.classList.remove('hidden');
_rateLimitModalOpen = true;
}
function closeRateLimitModal() {
const overlay = document.getElementById('rate-limit-modal-overlay');
if (overlay) overlay.classList.add('hidden');
if (_rateLimitCountdownInterval) {
clearInterval(_rateLimitCountdownInterval);
_rateLimitCountdownInterval = null;
}
_rateLimitModalOpen = false;
}
async function disconnectSpotifyFromRateLimit() {
closeRateLimitModal();
try {
showLoadingOverlay('Disconnecting Spotify...');
const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
const data = await response.json();
if (data.success) {
_spotifyRateLimitShown = false;
showToast(`Spotify disconnected. Now using ${currentMusicSourceName}.`, 'success');
await fetchAndUpdateServiceStatus();
if (currentPage === 'discover') {
loadDiscoverPage();
}
} else {
showToast(`Failed to disconnect: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error disconnecting Spotify:', error);
showToast('Failed to disconnect Spotify', 'error');
} finally {
hideLoadingOverlay();
}
}
function formatRateLimitDuration(seconds) {
if (!seconds || seconds <= 0) return '0s';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
async function authenticateTidal() {
try {
showLoadingOverlay('Saving credentials and starting Tidal authentication...');
// Save settings first to ensure credentials are persisted
await saveSettings();
showToast('Tidal authentication started', 'success');
window.open('/auth/tidal', '_blank');
} catch (error) {
console.error('Error authenticating Tidal:', error);
showToast('Failed to start Tidal authentication', 'error');
} finally {
hideLoadingOverlay();
}
}
async function authenticateDeezer() {
try {
showLoadingOverlay('Saving credentials and starting Deezer authentication...');
await saveSettings();
showToast('Deezer authentication started', 'success');
window.open('/auth/deezer', '_blank');
} catch (error) {
console.error('Error authenticating Deezer:', error);
showToast('Failed to start Deezer authentication', 'error');
} finally {
hideLoadingOverlay();
}
}
// ===== Tidal Download Auth (Device Flow) =====
async function testHiFiConnection() {
const statusEl = document.getElementById('hifi-connection-status');
const btn = document.getElementById('hifi-test-btn');
if (!statusEl) return;
statusEl.textContent = 'Checking...';
statusEl.style.color = '#aaa';
try {
const resp = await fetch('/api/hifi/status');
const data = await resp.json();
if (data.available) {
statusEl.textContent = `Connected (v${data.version || '?'})`;
statusEl.style.color = '#4caf50';
} else {
statusEl.textContent = 'No instances reachable';
statusEl.style.color = '#ff9800';
}
} catch (e) {
statusEl.textContent = 'Connection error';
statusEl.style.color = '#f44336';
}
}
async function testLidarrConnection() {
const statusEl = document.getElementById('lidarr-connection-status');
if (!statusEl) return;
statusEl.textContent = 'Checking...';
statusEl.style.color = '#aaa';
try {
// Save settings first so the backend has the URL/key
await saveSettings();
const resp = await fetch('/api/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: 'lidarr' })
});
const data = await resp.json();
if (data.success) {
statusEl.textContent = 'Connected';
statusEl.style.color = '#4caf50';
} else {
statusEl.textContent = data.error || 'Connection failed';
statusEl.style.color = '#f44336';
}
} catch (e) {
statusEl.textContent = 'Connection error';
statusEl.style.color = '#f44336';
}
}
async function checkHiFiInstances() {
const panel = document.getElementById('hifi-instances-panel');
const btn = document.getElementById('hifi-instances-check-btn');
if (!panel) return;
panel.style.display = 'block';
panel.innerHTML = '
`;
container.insertAdjacentHTML('beforeend', cardHtml);
// Store state for UI management (but backend remains source of truth)
youtubePlaylistStates[urlHash] = {
phase: phase,
url: playlistInfo.url,
playlist: playlist,
cardElement: document.getElementById(`youtube-card-${urlHash}`),
discoveryResults: [],
discoveryProgress: playlistInfo.discovery_progress,
spotifyMatches: playlistInfo.spotify_matches,
convertedSpotifyPlaylistId: playlistInfo.converted_spotify_playlist_id,
backendSynced: true // Flag to indicate this came from backend
};
console.log(`๐ Created YouTube card from backend state: ${playlist.name} (${phase})`);
}
function getActionButtonText(phase) {
switch (phase) {
case 'fresh': return 'Discover';
case 'discovering': return 'View Progress';
case 'discovered': return 'View Results';
case 'syncing': return 'View Sync';
case 'sync_complete': return 'Download';
case 'downloading': return 'View Downloads';
case 'download_complete': return 'Complete';
default: return 'Open';
}
}
function getPhaseText(phase) {
switch (phase) {
case 'fresh': return 'Ready to discover';
case 'discovering': return 'Discovering...';
case 'discovered': return 'Discovery Complete';
case 'syncing': return 'Syncing...';
case 'sync_complete': return 'Sync Complete';
case 'downloading': return 'Downloading...';
case 'download_complete': return 'Download Complete';
default: return phase;
}
}
function getPhaseColor(phase) {
switch (phase) {
case 'fresh': return '#999';
case 'discovering': case 'syncing': case 'downloading': return '#ffa500';
case 'discovered': case 'sync_complete': case 'download_complete': return 'rgb(var(--accent-rgb))';
default: return '#999';
}
}
function getProgressWidth(playlistInfo) {
if (playlistInfo.phase === 'fresh') return 0;
if (playlistInfo.spotify_total === 0) return 0;
return Math.round((playlistInfo.spotify_matches / playlistInfo.spotify_total) * 100);
}
async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) {
// Rehydrate a YouTube playlist's discovery modal state (similar to rehydrateModal)
const urlHash = playlistInfo.url_hash;
const playlistName = playlistInfo.playlist_name;
const phase = playlistInfo.phase;
console.log(`๐ง Rehydrating YouTube playlist "${playlistName}" (Phase: ${phase}) - User requested: ${userRequested}`);
try {
// First, ensure the card exists (create from backend if needed)
if (!youtubePlaylistStates[urlHash] || !youtubePlaylistStates[urlHash].cardElement) {
console.log(`๐ Creating missing YouTube card for rehydration: ${playlistName}`);
// Since playlistInfo from active processes doesn't have full playlist data,
// we need to fetch it from the backend first
try {
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
if (stateResponse.ok) {
const fullPlaylistState = await stateResponse.json();
createYouTubeCardFromBackendState(fullPlaylistState);
} else {
console.error(`โ Could not fetch full playlist state for card creation: ${playlistName}`);
return; // Can't create card without playlist data
}
} catch (error) {
console.error(`โ Error fetching playlist state for card creation: ${error.message}`);
return;
}
}
// Fetch full state from backend to get discovery results
let fullState = null;
if (phase !== 'fresh' && phase !== 'discovering') {
try {
console.log(`๐ Fetching full backend state for: ${playlistName}`);
const stateResponse = await fetch(`/api/youtube/state/${urlHash}`);
if (stateResponse.ok) {
fullState = await stateResponse.json();
console.log(`๐ Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`);
}
} catch (error) {
console.warn(`โ ๏ธ Could not fetch full state for ${playlistName}:`, error.message);
}
}
// Update local state to match backend
const state = youtubePlaylistStates[urlHash];
state.phase = phase;
state.discoveryProgress = playlistInfo.discovery_progress;
state.spotifyMatches = playlistInfo.spotify_matches;
state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id;
// Restore discovery results if we have them
if (fullState && fullState.discovery_results) {
state.discoveryResults = fullState.discovery_results;
state.syncPlaylistId = fullState.sync_playlist_id;
state.syncProgress = fullState.sync_progress || {};
console.log(`โ Restored ${state.discoveryResults.length} discovery results from backend`);
// Update modal if it already exists
const existingModal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (existingModal && !existingModal.classList.contains('hidden')) {
console.log(`๐ Refreshing existing modal with restored discovery results`);
refreshYouTubeDiscoveryModalTable(urlHash);
}
}
// Update card display
updateYouTubeCardPhase(urlHash, phase);
updateYouTubeCardProgress(urlHash, playlistInfo);
// Handle active polling resumption
if (phase === 'discovering') {
console.log(`๐ Resuming discovery polling for: ${playlistName}`);
startYouTubeDiscoveryPolling(urlHash);
} else if (phase === 'syncing') {
console.log(`๐ Resuming sync polling for: ${playlistName}`);
startYouTubeSyncPolling(urlHash);
}
// Open modal if user requested
if (userRequested) {
switch (phase) {
case 'discovering':
case 'discovered':
case 'syncing':
case 'sync_complete':
openYouTubeDiscoveryModal(urlHash);
break;
case 'downloading':
case 'download_complete':
// Open download modal if we have the converted playlist ID
if (playlistInfo.converted_spotify_playlist_id) {
await openDownloadMissingModal(playlistInfo.converted_spotify_playlist_id);
}
break;
}
}
console.log(`โ Successfully rehydrated YouTube playlist: ${playlistName}`);
} catch (error) {
console.error(`โ Error rehydrating YouTube playlist "${playlistName}":`, error);
}
}
async function removeYouTubePlaylistFromBackend(event, urlHash) {
// Remove YouTube playlist from backend storage and update UI
event.stopPropagation(); // Prevent card click
const state = youtubePlaylistStates[urlHash];
if (!state) return;
const playlistName = state.playlist.name;
try {
console.log(`๐๏ธ Removing YouTube playlist from backend: ${playlistName}`);
const response = await fetch(`/api/youtube/delete/${urlHash}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete playlist');
}
// Remove card from UI
if (state.cardElement) {
state.cardElement.remove();
}
// Remove from client state
delete youtubePlaylistStates[urlHash];
// Stop any active polling
if (activeYouTubePollers[urlHash]) {
clearInterval(activeYouTubePollers[urlHash]);
delete activeYouTubePollers[urlHash];
}
// Close discovery modal if open
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (modal) {
modal.remove();
}
// Show placeholder if no cards left
const container = document.getElementById('youtube-playlist-container');
const cards = container.querySelectorAll('.youtube-playlist-card');
if (cards.length === 0) {
container.innerHTML = '
No YouTube playlists added yet. Parse a YouTube playlist URL above to get started!
`;
} else {
// Finished items get status and open button
let statusClass = '';
if (item.state.includes('Cancelled')) statusClass = 'status--cancelled';
else if (item.state.includes('Failed') || item.state.includes('Errored')) statusClass = 'status--failed';
else if (item.state.includes('Completed') || item.state.includes('Succeeded')) statusClass = 'status--completed';
actionButtonHTML = `
${item.state}
`;
}
html += `
${title}
from ${item.username}
${actionButtonHTML}
`;
}
container.innerHTML = html;
}
function updateTabCounts() {
const activeCount = Object.keys(activeDownloads).length;
const finishedCount = Object.keys(finishedDownloads).length;
const activeTabBtn = document.querySelector('.tab-btn[data-tab="active-queue"]');
const finishedTabBtn = document.querySelector('.tab-btn[data-tab="finished-queue"]');
if (activeTabBtn) activeTabBtn.textContent = `Download Queue (${activeCount})`;
if (finishedTabBtn) finishedTabBtn.textContent = `Finished (${finishedCount})`;
}
function updateDownloadStats() {
const activeCount = Object.keys(activeDownloads).length;
const finishedCount = Object.keys(finishedDownloads).length;
const activeLabel = document.getElementById('active-downloads-label');
const finishedLabel = document.getElementById('finished-downloads-label');
if (activeLabel) activeLabel.textContent = `โข Active Downloads: ${activeCount}`;
if (finishedLabel) finishedLabel.textContent = `โข Finished Downloads: ${finishedCount}`;
}
function initializeDownloadTabs() {
const tabButtons = document.querySelectorAll('.tab-btn');
tabButtons.forEach(btn => {
btn.addEventListener('click', () => switchDownloadTab(btn));
});
}
function switchDownloadTab(button) {
const targetTabId = button.getAttribute('data-tab');
// Update buttons
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Update content panes
document.querySelectorAll('.download-queue').forEach(queue => queue.classList.remove('active'));
const targetQueue = document.getElementById(targetTabId);
if (targetQueue) targetQueue.classList.add('active');
}
async function cancelDownloadItem(downloadId, username) {
try {
const response = await fetch('/api/downloads/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ download_id: downloadId, username: username })
});
const result = await response.json();
if (result.success) {
showToast('Download cancelled', 'success');
} else {
showToast(`Failed to cancel: ${result.error}`, 'error');
}
} catch (error) {
console.error('Error cancelling download:', error);
showToast('Error sending cancel request', 'error');
}
}
async function clearFinishedDownloads() {
const finishedCount = Object.keys(finishedDownloads).length;
if (finishedCount === 0) {
showToast('No finished downloads to clear', 'error');
return;
}
try {
const response = await fetch('/api/downloads/clear-finished', {
method: 'POST'
});
const result = await response.json();
if (result.success) {
showToast('Finished downloads cleared', 'success');
} else {
showToast(`Failed to clear: ${result.error}`, 'error');
}
} catch (error) {
console.error('Error clearing finished downloads:', error);
showToast('Error sending clear request', 'error');
}
}
async function cancelAllDownloads() {
if (!await showConfirmDialog({ title: 'Cancel All Downloads', message: 'Cancel ALL active downloads and clear the transfer list? This cannot be undone.', confirmText: 'Cancel All', destructive: true })) {
return;
}
try {
const response = await fetch('/api/downloads/cancel-all', {
method: 'POST'
});
const result = await response.json();
if (result.success) {
showToast('All downloads cancelled and cleared', 'success');
} else {
showToast(`Failed to cancel: ${result.error}`, 'error');
}
} catch (error) {
console.error('Error cancelling all downloads:', error);
showToast('Error cancelling downloads', 'error');
}
}
// REPLACE the old performDownloadsSearch function with this new one.
async function performDownloadsSearch() {
const query = document.getElementById('downloads-search-input').value.trim();
if (!query) {
showToast('Please enter a search term', 'error');
return;
}
// --- UI Element References ---
const searchInput = document.getElementById('downloads-search-input');
const searchButton = document.getElementById('downloads-search-btn');
const cancelButton = document.getElementById('downloads-cancel-btn');
const statusText = document.getElementById('search-status-text');
const spinner = document.querySelector('.spinner-animation');
const dots = document.querySelector('.dots-animation');
// --- Start a new AbortController for this search ---
searchAbortController = new AbortController();
try {
// --- 1. Update UI to "Searching" State ---
searchInput.disabled = true;
searchButton.disabled = true;
cancelButton.classList.remove('hidden');
spinner.classList.remove('hidden');
dots.classList.remove('hidden');
statusText.textContent = `Searching for '${query}'...`;
displayDownloadsResults([]); // Clear previous results
// --- 2. Perform the Fetch Request ---
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
signal: searchAbortController.signal // Link fetch to the AbortController
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const results = data.results || [];
allSearchResults = results;
resetFilters();
applyFiltersAndSort();
// --- 3. Update UI with Success State ---
if (results.length === 0) {
statusText.textContent = `No results found for '${query}'`;
showToast('No results found', 'error');
} else {
document.getElementById('filters-container').classList.remove('hidden');
// Count albums and singles like the GUI app
let totalAlbums = 0;
let totalTracks = 0;
results.forEach(result => {
if (result.result_type === 'album') {
totalAlbums++;
} else {
totalTracks++;
}
});
statusText.textContent = `โจ Found ${results.length} results โข ${totalAlbums} albums, ${totalTracks} singles`;
showToast(`Found ${results.length} results`, 'success');
}
} catch (error) {
// --- 4. Handle Errors, Including Cancellation ---
if (error.name === 'AbortError') {
// This specific error is thrown when the user clicks "Cancel"
statusText.textContent = 'Search was cancelled.';
showToast('Search cancelled', 'info');
displayDownloadsResults([]); // Clear any partial results
} else {
console.error('Search failed:', error);
statusText.textContent = `Search failed: ${error.message}`;
showToast('Search failed', 'error');
}
} finally {
// --- 5. Clean Up UI Regardless of Outcome ---
searchInput.disabled = false;
searchButton.disabled = false;
cancelButton.classList.add('hidden');
spinner.classList.add('hidden');
dots.classList.add('hidden');
searchAbortController = null; // Clear the controller
}
}
function displayDownloadsResults(results) {
const resultsArea = document.getElementById('search-results-area');
if (!resultsArea) return;
if (!results.length) {
resultsArea.innerHTML = '
No search results found.
';
return;
}
let html = '';
results.forEach((result, index) => {
const isAlbum = result.result_type === 'album';
if (isAlbum) {
const trackCount = result.tracks ? result.tracks.length : 0;
const totalSize = result.total_size ? `${(result.total_size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size';
// Generate individual track items
let trackListHtml = '';
if (result.tracks && result.tracks.length > 0) {
// Detect disc boundaries from track number resets for multi-disc albums
let currentDisc = 1;
let lastTrackNum = 0;
let discBreaks = new Set();
result.tracks.forEach((track, trackIndex) => {
const tn = track.track_number || 0;
if (trackIndex > 0 && tn > 0 && tn <= lastTrackNum) {
currentDisc++;
discBreaks.add(trackIndex);
}
if (tn > 0) lastTrackNum = tn;
});
const isMultiDisc = discBreaks.size > 0;
if (isMultiDisc) {
trackListHtml += `
Disc 1
`;
}
let discNum = 1;
result.tracks.forEach((track, trackIndex) => {
if (discBreaks.has(trackIndex)) {
discNum++;
trackListHtml += `
';
results.innerHTML = h;
results.classList.add('visible');
_gsRenderTabs();
// Lazy load artist images for sources that don't provide them (iTunes/Deezer)
_gsLazyLoadArtistImages();
}
async function _gsLazyLoadArtistImages() {
const grid = document.getElementById('gsearch-artists-grid');
if (!grid) return;
const cards = grid.querySelectorAll('[data-needs-image="true"]');
if (cards.length === 0) return;
const activeSrc = _gsState.activeSource || 'spotify';
for (const card of cards) {
const artistId = card.dataset.artistId;
if (!artistId) continue;
try {
const res = await fetch(`/api/artist/${artistId}/image?source=${activeSrc}`);
const data = await res.json();
if (data.success && data.image_url) {
const artDiv = card.querySelector('.gsearch-item-art');
if (artDiv) artDiv.innerHTML = ``;
card.removeAttribute('data-needs-image');
}
} catch (e) { /* ignore */ }
}
}
function _gsRenderTabs() {
const el = document.getElementById('gsearch-tabs');
if (!el) return;
const sources = Object.keys(_gsState.sources);
const labels = {
spotify: 'Spotify',
itunes: 'Apple Music',
deezer: 'Deezer',
discogs: 'Discogs',
hydrabase: 'Hydrabase',
youtube_videos: 'Music Videos',
musicbrainz: 'MusicBrainz',
};
const visibleSources = sources.filter(s => {
const d = _gsState.sources[s] || {};
const count = s === 'youtube_videos'
? (d.videos?.length || 0)
: (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0);
const isLoading = !!(d._loading && d._loading.size > 0);
return isLoading || count > 0 || s === _gsState.activeSource;
});
if (visibleSources.length < 2) { el.style.display = 'none'; return; }
el.style.display = 'flex';
el.innerHTML = visibleSources.map(s => {
const d = _gsState.sources[s];
const c = s === 'youtube_videos'
? (d.videos?.length || 0)
: (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0);
return ``;
}).join('');
}
function _gsSwitchSource(src) {
_gsState._lastInteraction = Date.now();
_gsState.activeSource = src;
_gsRender(_gsState.data);
const input = document.getElementById('gsearch-input');
if (input) input.focus();
}
function _gsClickArtist(id, name, isLibrary) {
_gsDeactivate();
if (isLibrary) {
// Same as enhanced search: navigateToArtistDetail
navigateToArtistDetail(id, name);
} else {
// Same as enhanced search: navigate to Artists page + selectArtistForDetail
navigateToPage('artists');
setTimeout(() => {
selectArtistForDetail({ id, name, image_url: '' }, {
source: _gsState.activeSource || '',
});
}, 150);
}
}
async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) {
_gsDeactivate();
// Same flow as handleEnhancedSearchAlbumClick โ fetch album, open download modal
showLoadingOverlay('Loading album...');
try {
const params = new URLSearchParams({ name: albumName, artist: artistName });
if (source && source !== 'spotify') params.set('source', source);
const response = await fetch(`/api/spotify/album/${albumId}?${params}`);
if (!response.ok) throw new Error(`Failed to load album: ${response.status}`);
const albumData = await response.json();
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
hideLoadingOverlay();
showToast(`No tracks available for "${albumName}"`, 'warning');
return;
}
const enrichedTracks = albumData.tracks.map(t => ({
...t,
album: { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks }
}));
const virtualPlaylistId = `enhanced_search_album_${albumId}`;
const firstArtist = (albumData.artists || [])[0] || {};
const artistObj = { id: firstArtist.id || '', name: firstArtist.name || artistName, source: source || '' };
const albumObj = { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks, artists: albumData.artists || [{ name: artistName }] };
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, `[${artistName}] ${albumData.name}`, enrichedTracks, albumObj, artistObj, false);
// Register download bubble (same pattern as enhanced search)
registerSearchDownload(
{
id: albumData.id,
name: albumData.name,
artist: artistName,
image_url: albumData.images?.[0]?.url || imageUrl || null,
images: albumData.images || []
},
'album',
virtualPlaylistId,
artistName
);
} catch (e) {
hideLoadingOverlay();
showToast('Failed to load album: ' + e.message, 'error');
}
}
async function _gsClickTrack(artistName, trackName, albumName, trackId, imageUrl, durationMs) {
_gsDeactivate();
// Build enriched track + open download modal directly (same as enhanced search)
const virtualPlaylistId = `gsearch_track_${trackId || (artistName + '_' + trackName).replace(/\s/g, '_')}`;
const enrichedTrack = {
id: trackId || '',
name: trackName,
artists: [artistName],
album: { name: albumName || '', id: null, album_type: 'single', images: imageUrl ? [{ url: imageUrl }] : [], total_tracks: 1 },
duration_ms: durationMs || 0,
image_url: imageUrl || '',
};
const albumObject = {
name: albumName || '', id: null, album_type: 'single',
images: imageUrl ? [{ url: imageUrl }] : [],
artists: [{ name: artistName }], total_tracks: 1,
};
const artistObject = { id: null, name: artistName };
const playlistName = `${artistName} - ${trackName}`;
try {
showLoadingOverlay('Loading track...');
await openDownloadMissingModalForArtistAlbum(
virtualPlaylistId, playlistName, [enrichedTrack], albumObject, artistObject, false
);
// Register download bubble (same pattern as enhanced search)
registerSearchDownload(
{
id: trackId || '',
name: trackName,
artist: artistName,
image_url: imageUrl || null,
images: imageUrl ? [{ url: imageUrl }] : []
},
'track',
virtualPlaylistId,
artistName
);
} catch (e) {
console.error('Error opening track download:', e);
// Fallback: navigate to enhanced search
navigateToPage('downloads');
setTimeout(() => {
const input = document.getElementById('enhanced-search-input');
if (input) { input.value = `${artistName} ${trackName}`.trim(); input.dispatchEvent(new Event('input')); }
}, 300);
} finally {
hideLoadingOverlay();
}
}
async function _gsPlayTrack(trackName, artistName, albumName) {
try {
showToast('Searching for stream...', 'info');
const res = await fetch('/api/enhanced-search/stream-track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_name: trackName, artist_name: artistName, album_name: albumName })
});
const data = await res.json();
if (data.success && data.result) {
if (typeof startStream === 'function') {
startStream(data.result);
} else {
showToast('Streaming not available', 'error');
}
} else {
showToast(data.error || 'No stream found', 'error');
}
} catch (e) {
showToast('Stream failed: ' + e.message, 'error');
}
}
// Async library check for global search results โ adds badges + swaps play buttons
async function _gsLibraryCheck() {
try {
const src = _gsState.sources[_gsState.activeSource] || {};
const allAlbums = src.albums || [];
const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation');
const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep');
const tracks = src.tracks || [];
if (!allAlbums.length && !tracks.length) return;
const res = await fetch('/api/enhanced-search/library-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
albums: allAlbums.map(a => ({ name: a.name, artist: a.artist || (a.artists ? a.artists.join(', ') : '') })),
tracks: tracks.map(t => ({ name: t.name, artist: t.artist || (t.artists ? t.artists.join(', ') : '') })),
})
});
const checkData = await res.json();
// Add "In Library" badges to albums โ match by index against allAlbums order
const albumResults = checkData.albums || [];
let albumIdx = 0;
// Albums section
document.querySelectorAll('#gsearch-results .gsearch-results-body').forEach(body => {
// Find all gsearch-item elements and tag ones that are albums
const sections = body.querySelectorAll('.gsearch-section-header');
sections.forEach(header => {
const text = header.textContent;
const isAlbumSection = text.includes('Albums') || text.includes('Singles');
if (!isAlbumSection) return;
const grid = header.nextElementSibling;
if (!grid) return;
const items = grid.querySelectorAll('.gsearch-item');
items.forEach(item => {
if (albumIdx < albumResults.length && albumResults[albumIdx]) {
if (!item.querySelector('.gsearch-item-badge')) {
const badge = document.createElement('span');
badge.className = 'gsearch-item-badge';
badge.textContent = 'In Library';
item.appendChild(badge);
}
}
albumIdx++;
});
});
});
// Tag tracks + swap play buttons for library playback
const trackResults = checkData.tracks || [];
const trackEls = document.querySelectorAll('#gsearch-results .gsearch-track');
trackEls.forEach((el, i) => {
const tr = trackResults[i];
if (tr && tr.in_library) {
// Add badge
if (!el.querySelector('.gsearch-item-badge')) {
const badge = document.createElement('span');
badge.className = 'gsearch-item-badge';
badge.textContent = 'In Library';
badge.style.marginRight = '4px';
el.querySelector('.gsearch-track-dur')?.before(badge);
}
// Swap play button to library playback
if (tr.file_path) {
const playBtn = el.querySelector('.gsearch-play-btn');
if (playBtn) {
const newBtn = playBtn.cloneNode(true);
newBtn.removeAttribute('onclick');
newBtn.title = 'Play from library';
newBtn.style.background = 'rgba(76,175,80,0.15)';
newBtn.style.color = '#4caf50';
newBtn.addEventListener('click', e => {
e.stopPropagation();
playLibraryTrack(
{ id: tr.track_id, title: tr.title, file_path: tr.file_path, _stats_image: tr.album_thumb_url || null },
tr.album_title || '',
tr.artist_name || ''
);
});
playBtn.replaceWith(newBtn);
}
}
} else if (tr && tr.in_wishlist) {
if (!el.querySelector('.gsearch-item-badge')) {
const badge = document.createElement('span');
badge.className = 'gsearch-item-badge gsearch-wishlist-badge';
badge.textContent = 'In Wishlist';
badge.style.marginRight = '4px';
el.querySelector('.gsearch-track-dur')?.before(badge);
}
}
});
} catch (e) {
// Non-critical
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Escape a value for safe use inside a single-quoted JS string literal
* within a double-quoted HTML attribute (e.g. onclick="fn('${val}')").
*
* Layer 1 (JS): escape \ and ' so the JS string parses correctly.
* Layer 2 (HTML): escape &, ", <, > so the HTML attribute parses correctly.
* The browser applies these in reverse: HTML-decode first, then JS-execute.
*/
function escapeForInlineJs(str) {
if (str == null) return '';
return String(str)
.replace(/\\/g, '\\\\') // JS: literal backslash
.replace(/'/g, "\\'") // JS: single quote
.replace(/&/g, '&') // HTML: ampersand
.replace(/"/g, '"') // HTML: double quote
.replace(//g, '>'); // HTML: greater-than
}
function formatArtists(artists) {
if (!artists || !Array.isArray(artists)) {
return 'Unknown Artist';
}
// Handle both string arrays and object arrays with 'name' property
const artistNames = artists.map(artist => {
let artistName;
if (typeof artist === 'string') {
artistName = artist;
} else if (artist && typeof artist === 'object' && artist.name) {
artistName = artist.name;
} else {
artistName = 'Unknown Artist';
}
// Clean featured artists from the name
return cleanArtistName(artistName);
});
return artistNames.join(', ') || 'Unknown Artist';
}
async function checkForUpdates() {
try {
const res = await fetch('/api/update-check');
if (!res.ok) return;
const data = await res.json();
const btn = document.querySelector('.version-button');
if (!btn) return;
if (data.update_available) {
const dismissed = localStorage.getItem('soulsync-update-dismissed');
if (dismissed !== data.latest_sha) {
// Add glow class
btn.classList.add('update-available');
// Add UPDATE badge if not already present
if (!btn.querySelector('.update-badge')) {
const badge = document.createElement('span');
badge.className = 'update-badge';
badge.textContent = 'UPDATE';
btn.appendChild(badge);
}
// Show toast on first detection (not if already notified this session)
const notified = sessionStorage.getItem('soulsync-update-notified');
if (notified !== data.latest_sha) {
sessionStorage.setItem('soulsync-update-notified', data.latest_sha);
showToast(data.is_docker
? 'A new SoulSync update has been pushed to the repo โ Docker image will be updated soon!'
: 'A new SoulSync update is available!', 'info');
}
}
} else {
btn.classList.remove('update-available');
const badge = btn.querySelector('.update-badge');
if (badge) badge.remove();
}
} catch (e) {
console.debug('Update check failed:', e);
}
}
async function showVersionInfo() {
// Check update status before dismissing so we can pass it to the modal
let updateInfo = null;
const btn = document.querySelector('.version-button');
const hadUpdate = btn && btn.classList.contains('update-available');
// Dismiss update glow when user opens the modal
if (hadUpdate) {
btn.classList.remove('update-available');
const badge = btn.querySelector('.update-badge');
if (badge) badge.remove();
try {
const updateRes = await fetch('/api/update-check');
if (updateRes.ok) {
updateInfo = await updateRes.json();
if (updateInfo.latest_sha) {
localStorage.setItem('soulsync-update-dismissed', updateInfo.latest_sha);
}
}
} catch (e) { /* ignore */ }
}
try {
console.log('Fetching version info...');
// Fetch version data from API
const response = await fetch('/api/version-info');
if (!response.ok) {
throw new Error('Failed to fetch version info');
}
const versionData = await response.json();
console.log('Version data received:', versionData);
// Populate modal content
populateVersionModal(versionData, hadUpdate ? updateInfo : null);
// Show modal
const modalOverlay = document.getElementById('version-modal-overlay');
modalOverlay.classList.remove('hidden');
console.log('Version modal opened');
} catch (error) {
console.error('Error showing version info:', error);
showToast('Failed to load version information', 'error');
}
}
function closeVersionModal() {
const modalOverlay = document.getElementById('version-modal-overlay');
modalOverlay.classList.add('hidden');
console.log('Version modal closed');
}
function populateVersionModal(versionData, updateInfo) {
const container = document.getElementById('version-content-container');
if (!container) {
console.error('Version content container not found');
return;
}
// Update header with dynamic data
const titleElement = document.querySelector('.version-modal-title');
const subtitleElement = document.querySelector('.version-modal-subtitle');
if (titleElement) titleElement.textContent = versionData.title;
if (subtitleElement) subtitleElement.textContent = versionData.subtitle;
// Clear existing content
container.innerHTML = '';
// Show update banner if an update was available when modal was opened
if (updateInfo && updateInfo.update_available) {
const banner = document.createElement('div');
banner.className = 'version-update-banner';
const isDocker = updateInfo.is_docker;
banner.innerHTML = `
⬆
${isDocker ? 'Repo update detected' : 'New update available'}${isDocker
? 'A new update has been pushed to the repo. The Docker image will be updated soon โ no action needed yet.'
: `Your version: ${updateInfo.current_sha || 'unknown'} → Latest: ${updateInfo.latest_sha || 'unknown'}`}
`;
container.appendChild(banner);
}
// Create sections
versionData.sections.forEach(section => {
const sectionDiv = document.createElement('div');
sectionDiv.className = 'version-feature-section';
// Section title
const titleDiv = document.createElement('div');
titleDiv.className = 'version-section-title';
titleDiv.textContent = section.title;
sectionDiv.appendChild(titleDiv);
// Section description
const descDiv = document.createElement('div');
descDiv.className = 'version-section-description';
descDiv.textContent = section.description;
sectionDiv.appendChild(descDiv);
// Features list
const featuresList = document.createElement('ul');
featuresList.className = 'version-feature-list';
section.features.forEach(feature => {
const featureItem = document.createElement('li');
featureItem.className = 'version-feature-item';
featureItem.textContent = feature;
featuresList.appendChild(featureItem);
});
sectionDiv.appendChild(featuresList);
// Usage note (if present)
if (section.usage_note) {
const usageDiv = document.createElement('div');
usageDiv.className = 'version-usage-note';
usageDiv.textContent = `๐ก ${section.usage_note}`;
sectionDiv.appendChild(usageDiv);
}
container.appendChild(sectionDiv);
});
console.log('Version modal content populated');
}
// ===============================
// ADDITIONAL STYLES FOR SEARCH RESULTS
// ===============================
// Add dynamic styles for search results (since they're created dynamically)
const additionalStyles = `
`;
// Inject additional styles
document.head.insertAdjacentHTML('beforeend', additionalStyles);
// ============================================================================
// DISCOVERY FIX MODAL - Manual Track Matching
// ============================================================================
// Global state for discovery fix
let currentDiscoveryFix = {
platform: null, // 'youtube', 'tidal', 'beatport'
identifier: null, // url_hash or playlist_id
trackIndex: null,
sourceTrack: null,
sourceArtist: null
};
// Store event handler reference to allow proper removal
let discoveryFixEnterHandler = null;
/**
* Open discovery fix modal for a specific track
*/
function openDiscoveryFixModal(platform, identifier, trackIndex) {
console.log(`๐ง Opening fix modal: ${platform} - ${identifier} - track ${trackIndex}`);
// Get the discovery state
// Note: Beatport, Tidal, and ListenBrainz have their own states, but reuse YouTube modal infrastructure
let state, result;
if (platform === 'youtube') {
// Check both states - ListenBrainz also uses YouTube modal infrastructure
state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
} else if (platform === 'tidal') {
state = youtubePlaylistStates[identifier]; // Tidal uses YouTube state infrastructure
} else if (platform === 'beatport') {
state = youtubePlaylistStates[identifier]; // Beatport uses YouTube state infrastructure
} else if (platform === 'listenbrainz') {
state = listenbrainzPlaylistStates[identifier]; // ListenBrainz has its own state
} else if (platform === 'deezer') {
state = youtubePlaylistStates[identifier]; // Deezer uses YouTube state infrastructure
} else if (platform === 'mirrored') {
state = youtubePlaylistStates[identifier]; // Mirrored playlists use YouTube state infrastructure
}
// Support both camelCase and snake_case for discovery results
const results = state?.discoveryResults || state?.discovery_results;
result = results?.[trackIndex];
if (!result) {
console.error('โ Track data not found');
console.error(' Platform:', platform);
console.error(' Identifier:', identifier);
console.error(' State:', state);
console.error(' Discovery results (camelCase):', state?.discoveryResults?.length);
console.error(' Discovery results (snake_case):', state?.discovery_results?.length);
showToast('Track data not found', 'error');
return;
}
console.log('โ Found result:', result);
// Store context
currentDiscoveryFix = {
platform,
identifier,
trackIndex,
sourceTrack: result.lb_track || result.yt_track || result.tidal_track?.name || result.beatport_track?.title || result.track_name || 'Unknown Track',
sourceArtist: result.lb_artist || result.yt_artist || result.tidal_track?.artist || result.beatport_track?.artist || result.artist_name || 'Unknown Artist'
};
// Find the fix modal within the active discovery modal
const discoveryModal = document.getElementById(`youtube-discovery-modal-${identifier}`);
if (!discoveryModal) {
console.error('โ Discovery modal not found:', identifier);
showToast('Discovery modal not found', 'error');
return;
}
const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay');
if (!fixModalOverlay) {
console.error('โ Fix modal not found within discovery modal');
showToast('Fix modal not found', 'error');
return;
}
console.log('๐ Source track:', currentDiscoveryFix.sourceTrack);
console.log('๐ Source artist:', currentDiscoveryFix.sourceArtist);
console.log('๐ Fix modal overlay found:', fixModalOverlay);
// Populate modal - scope within the specific fix modal overlay to handle duplicate IDs
const sourceTrackEl = fixModalOverlay.querySelector('#fix-modal-source-track');
const sourceArtistEl = fixModalOverlay.querySelector('#fix-modal-source-artist');
const trackInput = fixModalOverlay.querySelector('#fix-modal-track-input');
const artistInput = fixModalOverlay.querySelector('#fix-modal-artist-input');
console.log('๐ Elements found:', {
sourceTrackEl,
sourceArtistEl,
trackInput,
artistInput
});
if (!sourceTrackEl || !sourceArtistEl || !trackInput || !artistInput) {
console.error('โ Fix modal elements not found in DOM');
showToast('Fix modal not properly initialized', 'error');
return;
}
sourceTrackEl.textContent = currentDiscoveryFix.sourceTrack;
sourceArtistEl.textContent = currentDiscoveryFix.sourceArtist;
trackInput.value = currentDiscoveryFix.sourceTrack;
artistInput.value = currentDiscoveryFix.sourceArtist;
console.log('โ Populated modal with:', {
track: trackInput.value,
artist: artistInput.value
});
// Remove old enter key handler if exists
if (discoveryFixEnterHandler) {
trackInput.removeEventListener('keypress', discoveryFixEnterHandler);
artistInput.removeEventListener('keypress', discoveryFixEnterHandler);
}
// Add new enter key handler
discoveryFixEnterHandler = function (e) {
if (e.key === 'Enter') searchDiscoveryFix();
};
trackInput.addEventListener('keypress', discoveryFixEnterHandler);
artistInput.addEventListener('keypress', discoveryFixEnterHandler);
// Show modal BEFORE auto-search so elements are visible
fixModalOverlay.classList.remove('hidden');
console.log('โ Fix modal opened, starting auto-search...');
// Auto-search with initial values (delay allows modal layout to settle and prevents accidental clicks)
setTimeout(() => searchDiscoveryFix(), 500);
}
/**
* Close discovery fix modal
*/
function closeDiscoveryFixModal() {
if (!currentDiscoveryFix.identifier) {
console.warn('No active fix modal to close');
return;
}
const discoveryModal = document.getElementById(`youtube-discovery-modal-${currentDiscoveryFix.identifier}`);
if (discoveryModal) {
const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay');
if (fixModalOverlay) {
fixModalOverlay.classList.add('hidden');
}
}
currentDiscoveryFix = { platform: null, identifier: null, trackIndex: null, sourceTrack: null, sourceArtist: null };
}
/**
* Search for tracks in Spotify
*/
async function searchDiscoveryFix() {
if (!currentDiscoveryFix.identifier) {
console.error('No active fix modal context');
return;
}
const discoveryModal = document.getElementById(`youtube-discovery-modal-${currentDiscoveryFix.identifier}`);
if (!discoveryModal) {
console.error('Discovery modal not found');
return;
}
const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay');
if (!fixModalOverlay) {
console.error('Fix modal not found');
return;
}
const trackInput = fixModalOverlay.querySelector('#fix-modal-track-input').value.trim();
const artistInput = fixModalOverlay.querySelector('#fix-modal-artist-input').value.trim();
if (!trackInput && !artistInput) {
showToast('Enter track name or artist', 'error');
return;
}
const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results');
// Build search params
const params = new URLSearchParams();
if (trackInput) params.set('track', trackInput);
if (artistInput) params.set('artist', artistInput);
if (!trackInput && !artistInput) {
resultsContainer.innerHTML = '
Enter a track name or artist.
';
return;
}
params.set('limit', '50');
// Use the user's active metadata source first, then fall back to others
const activeSource = (currentMusicSourceName || 'Spotify').toLowerCase();
const allSources = [
{ key: 'spotify', endpoint: '/api/spotify/search_tracks', label: 'Spotify' },
{ key: 'deezer', endpoint: '/api/deezer/search_tracks', label: 'Deezer' },
{ key: 'itunes', endpoint: '/api/itunes/search_tracks', label: 'iTunes' },
];
// Put the active source first, keep others as fallbacks
const activeIdx = allSources.findIndex(s => activeSource.includes(s.key));
const searchSources = activeIdx > 0
? [allSources[activeIdx], ...allSources.filter((_, i) => i !== activeIdx)]
: allSources;
resultsContainer.innerHTML = `
๐ Searching ${searchSources[0].label}...
`;
try {
for (let i = 0; i < searchSources.length; i++) {
const source = searchSources[i];
try {
const response = await fetch(`${source.endpoint}?${params.toString()}`);
const data = await response.json();
if (data.tracks && data.tracks.length > 0) {
renderDiscoveryFixResults(data.tracks, fixModalOverlay);
return;
}
// No results from this source โ show next source status if there is one
if (i < searchSources.length - 1) {
resultsContainer.innerHTML = `
`;
resultsContainer.appendChild(card);
});
}
/**
* User selected a track - update discovery state
*/
async function selectDiscoveryFixTrack(track) {
console.log('โ User selected track:', track);
// Confirm selection to prevent accidental clicks from layout shift
const artists = (track.artists || ['Unknown Artist']).join(', ');
if (!await showConfirmDialog({ title: 'Confirm Match', message: `Match to "${track.name}" by ${artists}?`, confirmText: 'Confirm' })) return;
const { platform, identifier, trackIndex } = currentDiscoveryFix;
console.log('๐ก Updating backend match:', { platform, identifier, trackIndex, track });
// Update backend
try {
// Get the correct backend identifier based on platform
let backendIdentifier = identifier;
if (platform === 'tidal') {
// For Tidal, backend expects the actual playlist_id, not url_hash
const state = youtubePlaylistStates[identifier];
backendIdentifier = state?.tidal_playlist_id || identifier;
} else if (platform === 'deezer') {
// For Deezer, backend expects the actual playlist_id, not url_hash
const state = youtubePlaylistStates[identifier];
backendIdentifier = state?.deezer_playlist_id || identifier;
} else if (platform === 'spotify_public') {
// For Spotify Public, backend expects the url_hash
const state = youtubePlaylistStates[identifier];
backendIdentifier = state?.spotify_public_playlist_id || identifier;
} else if (platform === 'beatport') {
// For Beatport, backend expects url_hash (same as identifier)
backendIdentifier = identifier;
}
// Mirrored playlists route through the YouTube endpoint (which already handles mirrored_ prefixes)
const apiPlatform = platform === 'mirrored' ? 'youtube' : (platform === 'spotify_public' ? 'spotify-public' : platform);
const requestBody = {
identifier: backendIdentifier,
track_index: trackIndex,
spotify_track: {
id: track.id,
name: track.name,
artists: track.artists,
album: track.album,
duration_ms: track.duration_ms
}
};
console.log('๐ก Request body:', requestBody);
console.log('๐ก Backend identifier:', backendIdentifier);
const response = await fetch(`/api/${apiPlatform}/discovery/update_match`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
console.log('๐ก Response status:', response.status);
const data = await response.json();
console.log('๐ก Response data:', data);
if (data.error) {
showToast(`Failed to update: ${data.error}`, 'error');
console.error('โ Backend update failed:', data.error);
return;
}
showToast('Match updated successfully!', 'success');
console.log('โ Backend update successful');
// Update frontend state
// Note: Beatport and Tidal reuse youtubePlaylistStates for discovery results
// ListenBrainz uses its own state but may also be accessed via YouTube
let state;
if (platform === 'youtube') {
state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
} else if (platform === 'tidal') {
state = youtubePlaylistStates[identifier];
} else if (platform === 'deezer') {
state = youtubePlaylistStates[identifier];
} else if (platform === 'beatport') {
state = youtubePlaylistStates[identifier];
} else if (platform === 'listenbrainz') {
state = listenbrainzPlaylistStates[identifier];
} else if (platform === 'mirrored') {
state = youtubePlaylistStates[identifier];
}
// Support both camelCase and snake_case
const results = state?.discoveryResults || state?.discovery_results;
if (state && results && results[trackIndex]) {
const result = results[trackIndex];
const wasNotFound = result.status !== 'found' && result.status_class !== 'found';
// Update result
result.status = 'โ Found';
result.status_class = 'found';
result.spotify_track = track.name;
result.spotify_artist = Array.isArray(track.artists) ? track.artists.join(', ') : track.artists;
result.spotify_album = track.album;
result.spotify_id = track.id;
result.duration = formatDuration(track.duration_ms);
result.manual_match = true;
// IMPORTANT: Also set spotify_data for download/sync compatibility
result.spotify_data = {
id: track.id,
name: track.name,
artists: track.artists,
album: track.album,
duration_ms: track.duration_ms
};
// Increment match count if this was previously not_found or error
if (wasNotFound) {
state.spotifyMatches = (state.spotifyMatches || 0) + 1;
// Update progress bar and text
const spotify_total = state.spotify_total || state.playlist?.tracks?.length || 0;
const progress = spotify_total > 0 ? Math.round((state.spotifyMatches / spotify_total) * 100) : 0;
const progressBar = document.getElementById(`youtube-discovery-progress-${identifier}`);
const progressText = document.getElementById(`youtube-discovery-progress-text-${identifier}`);
if (progressBar) {
progressBar.style.width = `${progress}%`;
}
if (progressText) {
progressText.textContent = `${state.spotifyMatches} / ${spotify_total} tracks matched (${progress}%)`;
}
console.log(`โ Updated progress: ${state.spotifyMatches}/${spotify_total} (${progress}%)`);
// Also update the Deezer playlist card if this is a Deezer fix
if (platform === 'deezer' && state.deezer_playlist_id) {
const deezerState = deezerPlaylistStates[state.deezer_playlist_id];
if (deezerState) {
deezerState.spotifyMatches = state.spotifyMatches;
updateDeezerCardProgress(state.deezer_playlist_id, {
spotify_matches: state.spotifyMatches,
spotify_total: spotify_total
});
}
}
// Also update the Tidal playlist card if this is a Tidal fix
if (platform === 'tidal' && state.tidal_playlist_id) {
const tidalState = tidalPlaylistStates?.[state.tidal_playlist_id];
if (tidalState) {
tidalState.spotifyMatches = state.spotifyMatches;
}
}
// Also update the Spotify Public playlist card if this is a Spotify Public fix
if (platform === 'spotify_public' && state.spotify_public_playlist_id) {
const spState = spotifyPublicPlaylistStates?.[state.spotify_public_playlist_id];
if (spState) {
spState.spotifyMatches = state.spotifyMatches;
updateSpotifyPublicCardProgress(state.spotify_public_playlist_id, {
spotify_matches: state.spotifyMatches,
spotify_total: spotify_total
});
}
}
}
// Update UI - refresh the table row
updateDiscoveryModalSingleRow(platform, identifier, trackIndex);
}
// Close modal
closeDiscoveryFixModal();
} catch (error) {
console.error('Error updating match:', error);
showToast('Failed to update match', 'error');
}
}
/**
* Update a single row in the discovery modal table
*/
function updateDiscoveryModalSingleRow(platform, identifier, trackIndex) {
// Check both state maps - ListenBrainz uses its own, others reuse youtubePlaylistStates
const state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
// Support both camelCase and snake_case
const results = state?.discoveryResults || state?.discovery_results;
if (!state || !results || !results[trackIndex]) {
console.warn(`Cannot update row: state or result not found`);
return;
}
const result = results[trackIndex];
const row = document.getElementById(`discovery-row-${identifier}-${trackIndex}`);
if (!row) {
console.warn(`Cannot update row: row element not found for ${identifier}-${trackIndex}`);
return;
}
// Update cells
const statusCell = row.querySelector('.discovery-status');
const spotifyTrackCell = row.querySelector('.spotify-track');
const spotifyArtistCell = row.querySelector('.spotify-artist');
const spotifyAlbumCell = row.querySelector('.spotify-album');
const actionsCell = row.querySelector('.discovery-actions');
if (statusCell) {
statusCell.textContent = result.status;
statusCell.className = `discovery-status ${result.status_class}`;
}
if (spotifyTrackCell) spotifyTrackCell.textContent = result.spotify_track || '-';
if (spotifyArtistCell) spotifyArtistCell.textContent = result.spotify_artist || '-';
if (spotifyAlbumCell) spotifyAlbumCell.textContent = result.spotify_album || '-';
// Update action button
if (actionsCell) {
actionsCell.innerHTML = generateDiscoveryActionButton(result, identifier, platform);
}
console.log(`โ Updated row ${trackIndex} in discovery modal`);
}
async function unmatchDiscoveryTrack(platform, identifier, trackIndex) {
// Determine the correct API base for this platform
const apiBase = platform === 'tidal' ? '/api/tidal'
: platform === 'deezer' ? '/api/deezer'
: platform === 'spotify-public' ? '/api/spotify-public'
: platform === 'beatport' ? '/api/beatport'
: platform === 'listenbrainz' ? '/api/listenbrainz'
: '/api/youtube';
try {
const response = await fetch(`${apiBase}/discovery/unmatch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, track_index: trackIndex })
});
const data = await response.json();
if (data.success) {
// Update the row in the discovery modal table
const state = youtubePlaylistStates[identifier]
|| (window.tidalDiscoveryStates && window.tidalDiscoveryStates[identifier])
|| {};
if (state.discovery_results && state.discovery_results[trackIndex]) {
const r = state.discovery_results[trackIndex];
r.status = 'โ Not Found';
r.status_class = 'not-found';
r.spotify_track = '-';
r.spotify_artist = '-';
r.spotify_album = '-';
r.spotify_data = null;
r.matched_data = null;
r.confidence = 0;
r.wing_it_fallback = false;
r.manual_match = false;
}
// Re-render the row โ discovery rows use id="discovery-row-{urlHash}-{index}"
const row = document.getElementById(`discovery-row-${identifier}-${trackIndex}`);
if (row) {
const statusCell = row.querySelector('.discovery-status');
if (statusCell) { statusCell.textContent = 'โ Not Found'; statusCell.className = 'discovery-status not-found'; }
const matchedCells = row.querySelectorAll('.spotify-track, .spotify-artist, .spotify-album');
matchedCells.forEach(c => c.textContent = '-');
const actionsCell = row.querySelector('.discovery-actions');
if (actionsCell) {
actionsCell.innerHTML = ``;
}
}
showToast('Match removed', 'success');
} else {
showToast(data.error || 'Failed to remove match', 'error');
}
} catch (e) {
console.error('Unmatch error:', e);
showToast('Failed to remove match', 'error');
}
}
// Make functions available globally for onclick handlers
window.openDiscoveryFixModal = openDiscoveryFixModal;
window.closeDiscoveryFixModal = closeDiscoveryFixModal;
window.searchDiscoveryFix = searchDiscoveryFix;
window.unmatchDiscoveryTrack = unmatchDiscoveryTrack;
window.openMatchingModal = openMatchingModal;
window.closeMatchingModal = closeMatchingModal;
window.selectArtist = selectArtist;
window.selectAlbum = selectAlbum;
window.navigateToPage = navigateToPage;
window.showSupportModal = showSupportModal;
window.closeSupportModal = closeSupportModal;
window.copyAddress = copyAddress;
window.retryLastSearch = retryLastSearch;
window.showVersionInfo = showVersionInfo;
window.closeVersionModal = closeVersionModal;
window.testConnection = testConnection;
window.autoDetectPlex = autoDetectPlex;
window.autoDetectJellyfin = autoDetectJellyfin;
window.autoDetectSlskd = autoDetectSlskd;
window.toggleServer = toggleServer;
window.authenticateSpotify = authenticateSpotify;
window.authenticateTidal = authenticateTidal;
window.togglePathLock = togglePathLock;
window.selectResult = selectResult;
window.startStream = startStream;
window.streamTrack = streamTrack;
window.streamAlbumTrack = streamAlbumTrack;
window.startDownload = startDownload;
window.downloadTrack = downloadTrack;
window.downloadAlbum = downloadAlbum;
window.toggleAlbumExpansion = toggleAlbumExpansion;
window.downloadAlbumTrack = downloadAlbumTrack;
window.switchDownloadTab = switchDownloadTab;
window.cancelDownloadItem = cancelDownloadItem;
window.clearFinishedDownloads = clearFinishedDownloads;
window.matchedDownloadTrack = matchedDownloadTrack;
window.matchedDownloadAlbum = matchedDownloadAlbum;
window.matchedDownloadAlbumTrack = matchedDownloadAlbumTrack;
/**
* Handle post-download cleanup: clear finished downloads from slskd.
* Scan and database update are now handled by system automations
* (batch_complete โ scan_library โ library_scan_completed โ start_database_update).
*/
async function handlePostDownloadAutomation(playlistId, process) {
try {
const successfulDownloads = getSuccessfulDownloadCount(process);
if (successfulDownloads === 0) {
console.log(`๐ [AUTO] No successful downloads for ${playlistId} - skipping cleanup`);
return;
}
console.log(`๐ [AUTO] Post-download cleanup for ${playlistId} (${successfulDownloads} successful downloads)`);
// Clear completed downloads from slskd
try {
const clearResponse = await fetch('/api/downloads/clear-finished', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (clearResponse.ok) {
console.log(`โ [AUTO] Completed downloads cleared`);
} else {
console.warn(`โ ๏ธ [AUTO] Clear downloads failed, continuing anyway`);
}
} catch (error) {
console.warn(`โ ๏ธ [AUTO] Clear error: ${error.message}`);
}
} catch (error) {
console.error(`โ [AUTO] Error in post-download cleanup: ${error.message}`);
}
}
/**
* Extract successful download count from a download process
*/
function getSuccessfulDownloadCount(process) {
try {
// For processes that have completed, check the modal for completed count
if (process && process.modalElement) {
const statElement = process.modalElement.querySelector('[id*="stat-downloaded-"]');
if (statElement && statElement.textContent) {
const count = parseInt(statElement.textContent, 10);
return isNaN(count) ? 0 : count;
}
}
// Fallback: assume successful if process completed without obvious failure
if (process && process.status === 'complete') {
return 1; // Conservative assumption for single download
}
return 0;
} catch (error) {
console.warn(`โ ๏ธ [AUTO] Error getting successful download count: ${error.message}`);
return 0;
}
}
// ===============================
// ADD TO WISHLIST MODAL FUNCTIONS
// ===============================
let currentWishlistModalData = null;
let wishlistModalVersion = 0;
/**
* Open the Add to Wishlist modal for an album/EP/single
* @param {Object} album - Album object with id, name, image_url, etc.
* @param {Object} artist - Artist object with id, name, image_url
* @param {Array} tracks - Array of track objects
* @param {string} albumType - Type of release (album, EP, single)
*/
async function openAddToWishlistModal(album, artist, tracks, albumType, trackOwnership) {
wishlistModalVersion++;
showLoadingOverlay('Preparing wishlist...');
console.log(`๐ต Opening Add to Wishlist modal for: ${artist.name} - ${album.name}`);
try {
// Store current modal data for use by other functions
currentWishlistModalData = {
album,
artist,
tracks,
albumType
};
const modal = document.getElementById('add-to-wishlist-modal');
const overlay = document.getElementById('add-to-wishlist-modal-overlay');
if (!modal || !overlay) {
console.error('Add to wishlist modal elements not found');
return;
}
// Generate and populate hero section
const heroContent = generateWishlistModalHeroSection(album, artist, tracks, albumType, trackOwnership);
const heroContainer = document.getElementById('add-to-wishlist-modal-hero');
if (heroContainer) {
heroContainer.innerHTML = heroContent;
}
// Generate and populate track list
const trackListHTML = generateWishlistTrackList(tracks, trackOwnership);
const trackListContainer = document.getElementById('wishlist-track-list');
if (trackListContainer) {
trackListContainer.innerHTML = trackListHTML;
}
// Set up the "Add to Wishlist" button click handler
const addToWishlistBtn = document.getElementById('confirm-add-to-wishlist-btn');
if (addToWishlistBtn) {
addToWishlistBtn.onclick = () => handleAddToWishlist();
}
// Show the modal
overlay.classList.remove('hidden');
hideLoadingOverlay();
console.log(`โ Successfully opened Add to Wishlist modal for: ${album.name}`);
} catch (error) {
console.error('โ Error opening Add to Wishlist modal:', error);
hideLoadingOverlay();
showToast(`Error opening wishlist modal: ${error.message}`, 'error');
}
}
/**
* Generate the hero section HTML for the wishlist modal
*/
function generateWishlistModalHeroSection(album, artist, tracks, albumType, trackOwnership) {
const artistImage = artist.image_url || '';
const albumImage = album.image_url || '';
const trackCount = tracks.length;
// Calculate missing tracks if ownership info is available
let trackDetailText = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
if (trackOwnership) {
const ownedCount = Object.values(trackOwnership).filter(v => v === true).length;
const missingCount = trackCount - ownedCount;
if (missingCount > 0 && ownedCount > 0) {
trackDetailText = `${missingCount} of ${trackCount} tracks missing`;
}
}
let heroBackgroundImage = '';
if (albumImage) {
heroBackgroundImage = ``;
}
const heroContent = `
${artistImage ? `` : ''}
${albumImage ? `` : ''}
${escapeHtml(album.name || 'Unknown Album')}
by ${escapeHtml(artist.name || 'Unknown Artist')}
${albumType || 'Album'}${trackDetailText}
`;
return `
${heroBackgroundImage}
${heroContent}
`;
}
/**
* Generate the track list HTML for the wishlist modal
*/
function generateWishlistTrackList(tracks, trackOwnership) {
if (!tracks || tracks.length === 0) {
return '
The Metadata Updater triggers all enrichment workers simultaneously, re-checking every item in your library against all connected services (Spotify, MusicBrainz, iTunes, Deezer, AudioDB, Last.fm, Genius, Tidal, Qobuz).
Refresh Interval Options
6 months: Only updates metadata for artists not updated in the last 180 days
3 months: Updates metadata for artists not updated in the last 90 days
1 month: Updates metadata for artists not updated in the last 30 days
Force All: Updates all artists regardless of when they were last updated
What gets updated?
Artist profile photos, genres, and descriptions
Album cover artwork, labels, and release info
Track ISRCs, explicit flags, and external IDs
Service match status for all 9 enrichment workers
Note
Available for Plex and Jellyfin media servers. Each enrichment worker only runs if its service is authenticated.
The Quality Scanner identifies tracks in your library that don't meet your preferred quality settings and automatically matches them to Spotify to add to your wishlist for re-downloading.
Scan Scope
Watchlist Artists Only: Only scans tracks from artists you're watching. Faster and more focused.
All Library Tracks: Scans your entire music library. Comprehensive but takes longer.
How it works
Scans tracks and checks file format against your quality preferences
Identifies tracks below your quality threshold (e.g., MP3 when you prefer FLAC)
Uses fuzzy matching to find the track on Spotify (70% confidence minimum)
Automatically adds matched tracks to your wishlist for re-download
The Duplicate Cleaner scans your output folder for duplicate audio files and automatically removes lower-quality versions, keeping only the best copy.
How it detects duplicates
Files are considered duplicates when:
They are in the same folder
They have the exact same filename (ignoring file extension)
Example: Song.flac and Song.mp3 in the same folder = duplicates โ
Example: Song.flac and Song (Remaster).flac = NOT duplicates โ
Which file is kept?
Priority order (best to worst):
Format priority: FLAC/Lossless > OPUS/OGG > M4A/AAC > MP3/WMA
If same format: Larger file size is kept (usually indicates better bitrate)
Where do deleted files go?
Removed files are moved to Transfer/deleted/ folder (not permanently deleted). You can review and recover them if needed.
Safety Features
Only processes audio files (FLAC, MP3, M4A, etc.)
Only removes files with identical names in the same folder
Files are moved, not deleted - fully recoverable
Preserves original folder structure in the deleted folder
Stats Explained
Files Scanned: Total audio files checked
Duplicates Found: Number of duplicate files detected
Deleted: Files moved to deleted folder
Space Freed: Total disk space reclaimed
`
},
'media-scan': {
title: 'Media Server Scan',
content: `
What does this tool do?
The Media Server Scan tool manually triggers a Plex media library scan to detect newly downloaded music files.
When to use it?
After downloading new tracks to refresh your Plex library
When new music isn't showing up in Plex
To force an immediate library update instead of waiting for auto-scan
What happens when you scan?
Plex library scan: Plex scans your music folder for new/changed files
Automatic database update: After the scan completes, SoulSync automatically updates its internal database with new tracks
Library refreshed: New music appears in Plex and SoulSync within moments
Plex only?
Yes! This tool only appears when Plex is your active media server because:
Jellyfin automatically detects new files instantly (real-time monitoring)
Navidrome automatically detects new files instantly (real-time monitoring)
Plex requires manual scans or has delayed auto-scanning
Stats Explained
Last Scan: Time of the most recent scan request
Status: Current scan state (Idle, Scanning, Error)
Scan workflow
This tool replicates the same scan process that runs automatically after completing a download modal - ensuring your new tracks are immediately available in your library!
The Retag Tool lets you fix metadata on files that have already been downloaded and processed. If an album was tagged with wrong metadata, you can search for the correct match and re-apply tags.
How it works
Browse your past downloads organized by artist
Expand an album or single to see individual tracks
Click Retag to search for the correct album match
Select the right album and confirm — metadata and file paths are updated automatically
What gets updated?
File tags: Title, artist, album, track number, genre, cover art
File paths: Files are moved/renamed to match new metadata (based on your path template)
Cover art: cover.jpg is updated in the album folder
Stats Explained
Groups: Number of album/single download groups tracked
Tracks: Total individual track files tracked
Artists: Number of unique artists across all groups
Notes
Only album and single downloads are tracked (not playlists)
Deleting a group from the list does not delete the files
The Discover page is your personalized music discovery hub. It uses your watchlist, library listening history, and MusicMap to surface new music, create curated playlists, and organize your collection in dynamic ways.
๐ฏ Hero Section (Featured Artists)
The rotating hero showcases similar artists discovered via MusicMap. These are artists you don't already have in your library but might enjoy based on your watchlist.
Auto-rotates every 8 seconds through 10 featured artists
Similar artists sourced from MusicMap and matched to Spotify
Click arrows to navigate manually
Add artists to watchlist or view their full discography
Data refreshed when watchlist scanner runs
๐ Recent Releases
New albums from artists you're watching and their MusicMap similar artists. Cached from Spotify and updated during watchlist scans.
Shows up to 20 recent albums
Click any album to view tracks and add to wishlist
Automatically filtered to show albums released in the last 90 days
Includes both watchlist artists and similar artists from MusicMap
๐ Seasonal Content (Auto-detected)
Seasonal albums and playlists that appear automatically based on the current season (Winter, Spring, Summer, Fall).
Seasonal Albums: Albums matching the current season's vibe
Seasonal Playlist: Curated playlist that refreshes with each season
Only visible during the matching season
Can download and sync to your media server
๐ต Fresh Tape (Release Radar)
Curated playlist of brand new releases from your discovery pool. Focuses on tracks released in the past 30 days.
50 tracks, refreshed weekly by watchlist scanner
Stays consistent until next update (not random)
Download missing tracks or sync to media server
Tracks from watchlist artists and MusicMap similar artists
๐ The Archives (Discovery Weekly)
Curated playlist from your entire discovery pool - a mix of new and catalog tracks from MusicMap discoveries.
50 tracks, refreshed weekly by watchlist scanner
Stays consistent until next update (not random)
Broader selection than Fresh Tape (includes older releases)
Download missing tracks or sync to media server
๐ Personalized Library Playlists
Playlists generated from your existing library using listening statistics:
Recently Added: Latest 50 tracks added to your library
Your Top 50: All-time most played tracks (requires play count data)
Forgotten Favorites: Tracks you loved but haven't played recently
๐ฒ Discovery Pool Playlists
Playlists generated from your discovery pool (tracks from watchlist/similar artists you don't own yet):
Popular Picks: High-popularity tracks (Spotify popularity 70+)
Fires when the watchlist scanner detects new music from an artist you're watching. This means a new album, EP, or single has been released that you don't already have.
Conditions
Artist: Only fire for specific watched artists
Available variables for notifications
{artist}, {new_tracks}, {added_to_wishlist}
Good for
Getting notified when your favorite artists drop new music
Auto-processing the wishlist immediately after new releases are found
Fires after a mirrored playlist is synced to your media server (Plex, Jellyfin, or Navidrome). This means the playlist has been matched and created/updated on your server.
Fires when Spotify/iTunes metadata discovery finishes for a mirrored playlist. Discovery is the process of matching playlist tracks to official Spotify or iTunes metadata.
Fires when the library database refresh finishes โ either incremental or full. This means SoulSync's internal database has been synced with your media server.
Available variables for notifications
{total_artists}, {total_albums}, {total_tracks}
Good for
Running a quality scan after the database is refreshed
Fires when a downloaded file fails AcoustID verification and is moved to the quarantine folder. This means the audio fingerprint didn't match what was expected โ the file might be the wrong song.
Conditions
Artist: Only fire for specific artists
Title: Match on track title
Available variables for notifications
{artist}, {title}, {reason}
What is quarantine?
Files that fail audio fingerprint verification are moved to a quarantine folder instead of your library. This prevents wrong songs from polluting your collection. You can review quarantined files manually.
Fires when the quality scanner finishes. The scanner identifies tracks below your quality preferences and adds them to your wishlist for re-downloading.
Fires when a media server library scan is considered complete. This only happens after a Scan Library action was triggered โ it cannot fire on its own.
How does it know the scan is done?
Your media server (Plex, Jellyfin, Navidrome) doesn't send a "scan finished" signal back to SoulSync. So after telling the server to scan, SoulSync waits approximately 5 minutes and then assumes the scan has finished. This is a generous estimate that works for most libraries.
Timing
From the moment a download finishes to when this trigger fires, expect roughly 6-7 minutes:
60 second debounce wait (groups multiple downloads together)
Media server scan triggered
~5 minute wait (assumed scan completion)
This event fires
Default use
The system automation Auto-Update Database After Scan listens for this trigger to start an incremental database update, keeping your SoulSync library in sync with your media server.
Available variables
{server_type} โ which media server was scanned (plex, jellyfin, navidrome)
Searches Soulseek for tracks in your wishlist and downloads them. This is the same process that runs automatically on a timer โ this action lets you trigger it manually or chain it to events.
Configuration
Category: Process all wishlist tracks, or only Albums/EPs, or only Singles
How it works
Picks tracks from the wishlist (alternating Albums and Singles cycles)
Tells your media server (Plex, Jellyfin, or Navidrome) to scan its music library folder for new or changed files. This makes newly downloaded music appear in your media server.
How it works
A 60 second debounce groups rapid requests โ if multiple downloads finish close together, only one scan is triggered
After the debounce, your media server is told to scan
SoulSync waits ~5 minutes (your media server doesn't report when it's finished, so this is an assumed completion time)
The Library Scan Done event fires, which can trigger follow-up actions like a database update
Default use
The system automation Auto-Scan After Downloads uses this action to automatically scan your library when a batch download completes. You can disable that automation if you prefer to scan manually.
Note
Jellyfin and Navidrome often detect new files automatically, but the scan ensures nothing is missed.
Syncs a mirrored playlist to your media server. It matches discovered tracks against your library and creates or updates the playlist on Plex, Jellyfin, or Navidrome.
Configuration
Playlist: Select which mirrored playlist to sync
Prerequisites
Tracks should be discovered first (matched to Spotify/iTunes metadata) before syncing. Undiscovered tracks will be skipped.
Finds official Spotify or iTunes metadata for tracks in a mirrored playlist. This is required before syncing โ it matches each track to a known release so it can be found in your library.
Configuration
Playlist: Select a specific playlist, or check "Discover all" to process all mirrored playlists
How it works
Takes each track name and artist from the mirror
Searches Spotify (or iTunes as fallback) for a match
Stores the best match with confidence score in the discovery cache
Already-discovered tracks are skipped for efficiency
Runs the full playlist lifecycle in one automation โ no signal wiring needed. Executes four phases sequentially:
Refresh โ Re-fetches playlist tracks from the source platform (Spotify, Tidal, YouTube, Deezer)
Discover โ Matches each track to official metadata (Spotify/iTunes/Deezer IDs)
Sync โ Pushes the playlist to your media server (Plex, Jellyfin, Navidrome)
Download Missing โ Queues unmatched tracks to the wishlist for automatic download
Configuration
Playlist: Select a specific mirrored playlist, or check "Process all" to run the pipeline for every mirrored playlist
Skip wishlist: Check this to skip the download phase (useful if you only want to sync, not download)
How the re-sync loop works
Set this on a schedule (e.g., every 6 hours). Between runs, the wishlist processor downloads missing tracks in the background. On the next pipeline run, those newly downloaded tracks will match during the sync phase โ so your server playlist gets more complete with each cycle until fully synced.
Replaces
This single automation replaces the 4-automation signal chain pattern (Refresh โ signal โ Discover โ signal โ Sync โ signal โ Download). No signals, no chaining, no room for misconfiguration.
Scans your output folder for duplicate audio files (same filename, different format) and removes the lower-quality version. For example, if you have both Song.flac and Song.mp3, the MP3 is removed.
Safety
Removed files are moved to a deleted/ subfolder, not permanently deleted. You can recover them if needed.
Permanently deletes all files in the quarantine folder. Quarantined files are downloads that failed AcoustID audio fingerprint verification โ they might be the wrong song.
Warning
This permanently deletes files. Make sure you've reviewed quarantined files before setting up an automation for this.
`
},
'auto-cleanup_wishlist': {
title: 'Clean Up Wishlist',
content: `
What does this action do?
Removes duplicate entries and tracks you already own from your wishlist. Keeps the wishlist lean by removing items that no longer need downloading.
Refreshes the discovery pool with new tracks from your mirrored playlists. The discovery pool tracks which playlist tracks have been successfully matched and which ones failed.
Scans your library for tracks that don't meet your quality preferences (e.g., MP3 when you prefer FLAC). Low-quality tracks are matched to Spotify and added to your wishlist for re-downloading in better quality.
Configuration
Scope: Scan only watchlist artists (faster) or your entire library (thorough)
Scrapes the Beatport homepage for top charts and caches the results locally. Keeps the Beatport charts page loading instantly without needing to scrape on every visit.
Cache duration
Cache lasts 24 hours. This action refreshes it early so it's always warm when you visit the charts page.
Good for
Keeping Beatport charts available instantly
Scheduling daily cache refreshes (e.g. every morning)
Clear Quarantine โ permanently deletes all quarantined files
Clear Download Queue โ removes completed, errored, and cancelled downloads from Soulseek
Sweep Empty Directories โ removes empty folders left behind in the input directory
Sweep Import Folder โ removes empty directories from the import folder
Clean Search History โ trims old Soulseek search queries
Safety
Skips download queue cleanup if batches are actively downloading or post-processing. Each step runs independently โ a failure in one step won't stop the others.
Good for
Scheduled housekeeping every 12 hours
Keeping disk usage and queue clutter under control
Walks your entire media server library and compares it against SoulSync's database. Adds any new tracks found and removes stale entries that no longer exist on the server.
How is this different from Database Update?
Database Update: Incremental โ only looks for new artists/albums added since last update
Deep Scan: Full comparison โ checks every track on the server against the database, catches anything missed
Safety
Never overwrites existing enrichment data (genres, Spotify IDs, artwork)
Only inserts tracks that don't already exist in the database
Stale track removal has a 50% safety threshold โ if more than half the library appears missing, removal is skipped
Sends an HTTP POST request with a JSON payload to any URL when the automation's action completes. Use it to integrate with Gotify, Home Assistant, Slack, n8n, or any service that accepts webhooks.
Configuration
URL: The endpoint to POST to (e.g. https://gotify.example.com/message?token=xxx)
Headers: Optional custom headers, one per line in Key: Value format. Useful for auth tokens.
Custom Message: Optional message with variable placeholders. Added as a "message" field in the JSON payload.
JSON payload
The POST body always includes all event variables as JSON fields:
{"time": "2026-04-02 ...", "name": "My Automation", "status": "success", ...}
Available variables
Use these in your message or header values:
{time} โ When the automation ran
{name} โ Automation name
{run_count} โ How many times this automation has run
{status} โ Result status of the action
`
},
// ==================== Signal System Help ====================
'auto-signal_received': {
title: 'Signal Received',
content: `
What is this trigger?
Fires when another automation sends a named signal using the Fire Signal then-action. This lets you chain automations together โ one automation finishes and wakes up another.
Configuration
Signal Name: The name to listen for (e.g. library_ready, scan_done). Must match the name used in the Fire Signal action.
How chaining works
Automation A: Trigger = Batch Complete, Action = Scan Library, Then = Fire Signal "scan_done"
Automation B: Trigger = Signal Received "scan_done", Action = Update Database
When a download finishes โ A scans library โ fires signal โ B wakes up โ updates database
Safety
Circular signal chains are detected and blocked when you save
Maximum chain depth of 5 levels to prevent runaway cascades
Same signal can only fire once every 10 seconds (cooldown)
Signal names
Use descriptive lowercase names with underscores: library_ready, scan_complete, downloads_done. Existing signal names from other automations appear as suggestions.
Fires a named signal after the automation's action completes. Any other automation with a Signal Received trigger listening for this signal name will wake up and run.
Configuration
Signal Name: The signal to fire (e.g. library_ready). Use the same name in a Signal Received trigger on another automation to connect them.
Use cases
Multi-step workflows: Scan library โ fire signal โ update database โ fire signal โ send notification
Fan-out: One signal can trigger multiple automations simultaneously
Decoupled logic: Keep each automation simple with one job, chain them via signals
Combining with notifications
You can add up to 3 then-actions per automation. For example: Fire Signal + Discord notification + Telegram notification โ all run after the action completes.
The Backup Manager lets you create, view, download, restore, and delete database backups directly from the dashboard.
Features
Backup Now: Create an instant backup of the current database using SQLite's hot-copy API
Download: Download any backup file to your local machine
Restore: Roll back the database to a previous backup state
Delete: Remove old backups you no longer need
Auto-Backups
SoulSync automatically creates a backup every 3 days via the automation engine. Up to 5 rolling backups are kept (oldest are removed when the limit is exceeded).
Restore Safety
When you restore from a backup, a safety backup of your current database is created first. This means you can always undo a restore if something goes wrong.
Stats Explained
Last Backup: When the most recent backup was created
The Metadata Cache stores every API response from Spotify and iTunes so SoulSync can reuse them instead of making duplicate API calls. This reduces rate limit pressure and speeds up lookups.
How it works
When SoulSync fetches artist, album, or track data from Spotify or iTunes, the response is cached locally. The next time the same data is needed, it's served from cache instantly โ no API call required. Cached data is even served during Spotify rate limit bans.
Browsing the Cache
Click Browse Cache to explore all cached metadata. You can filter by entity type (artists, albums, tracks), search by name, filter by source (Spotify/iTunes), and sort by different fields. Click any card to see full details including the raw API response.
Cache Management
TTL: Entities expire after 30 days, search mappings after 7 days
Eviction: Expired entries are automatically cleaned up
Clear: You can clear the entire cache or filter by source/type
Stats Explained
Artists: Total cached artist profiles
Albums: Total cached album records
Tracks: Total cached track records
Hits: Total number of times cached data was served instead of making an API call
`
}
};
function initializeToolHelpButtons() {
const helpButtons = document.querySelectorAll('.tool-help-button');
const modal = document.getElementById('tool-help-modal');
const closeButton = modal.querySelector('.tool-help-modal-close');
// Attach click handlers to all help buttons
helpButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.stopPropagation();
const toolId = button.getAttribute('data-tool');
openToolHelpModal(toolId);
});
});
// Close modal when clicking close button
closeButton.addEventListener('click', closeToolHelpModal);
// Close modal when clicking outside content
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeToolHelpModal();
}
});
// Close modal on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('active')) {
closeToolHelpModal();
}
});
}
function openToolHelpModal(toolId) {
const modal = document.getElementById('tool-help-modal');
const titleElement = document.getElementById('tool-help-modal-title');
const bodyElement = document.getElementById('tool-help-modal-body');
const helpData = TOOL_HELP_CONTENT[toolId];
if (!helpData) {
console.warn(`No help content found for tool: ${toolId}`);
return;
}
titleElement.textContent = helpData.title;
bodyElement.innerHTML = helpData.content;
modal.classList.add('active');
document.body.style.overflow = 'hidden'; // Prevent background scrolling
}
function closeToolHelpModal() {
const modal = document.getElementById('tool-help-modal');
if (modal) modal.classList.remove('active');
document.body.style.overflow = ''; // Restore scrolling
}
// Global Escape key handler for tool help modal (works even if Tools page wasn't visited)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const modal = document.getElementById('tool-help-modal');
if (modal && modal.classList.contains('active')) closeToolHelpModal();
}
});
// ===============================
// == RETAG TOOL FUNCTIONS ==
// ===============================
let retagStatusInterval = null;
let retagCurrentGroupId = null;
async function loadRetagStats() {
try {
const response = await fetch('/api/retag/stats');
const data = await response.json();
if (data.success !== false) {
const groupsEl = document.getElementById('retag-stat-groups');
const tracksEl = document.getElementById('retag-stat-tracks');
const artistsEl = document.getElementById('retag-stat-artists');
if (groupsEl) groupsEl.textContent = data.groups || 0;
if (tracksEl) tracksEl.textContent = data.tracks || 0;
if (artistsEl) artistsEl.textContent = data.artists || 0;
}
} catch (e) {
console.warn('Failed to load retag stats:', e);
}
}
async function openRetagModal() {
const modal = document.getElementById('retag-modal');
if (!modal) return;
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
// Reset batch bar and clear-all button
const batchBar = document.getElementById('retag-batch-bar');
if (batchBar) batchBar.style.display = 'none';
const clearBtn = document.getElementById('retag-clear-all-btn');
if (clearBtn) { clearBtn.textContent = 'Clear All'; clearBtn.dataset.confirming = ''; clearBtn.style.background = ''; }
const body = document.getElementById('retag-modal-body');
body.innerHTML = '
';
}
}
/**
* Show inline confirmation on a search result before retagging
*/
function showRetagConfirm(el, groupId, albumId, albumName) {
// Clear any other confirming states
document.querySelectorAll('.retag-search-result.retag-confirming').forEach(r => {
r.classList.remove('retag-confirming');
const bar = r.querySelector('.retag-result-confirm-bar');
if (bar) bar.remove();
r.onclick = r._originalOnclick || null;
});
el.classList.add('retag-confirming');
el._originalOnclick = el.onclick;
el.onclick = null; // Disable clicking the row again
const confirmBar = document.createElement('div');
confirmBar.className = 'retag-result-confirm-bar';
confirmBar.innerHTML = `
Re-tag with "${escapeHtml(albumName)}"?
`;
el.appendChild(confirmBar);
}
function cancelRetagConfirm(cancelBtn) {
const result = cancelBtn.closest('.retag-search-result');
if (!result) return;
result.classList.remove('retag-confirming');
const bar = result.querySelector('.retag-result-confirm-bar');
if (bar) bar.remove();
if (result._originalOnclick) {
result.onclick = result._originalOnclick;
}
}
async function executeRetag(groupId, albumId, albumName) {
closeRetagSearch();
closeRetagModal();
try {
const response = await fetch('/api/retag/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_id: groupId, album_id: albumId })
});
const data = await response.json();
if (data.success) {
showToast('Retag operation started', 'success');
startRetagPolling();
} else {
showToast(`Error: ${data.error || 'Unknown error'}`, 'error');
}
} catch (e) {
showToast('Failed to start retag operation', 'error');
}
}
function startRetagPolling() {
if (retagStatusInterval) return;
retagStatusInterval = setInterval(checkRetagStatus, 1000);
checkRetagStatus();
}
async function checkRetagStatus() {
if (socketConnected) return; // WebSocket handles this
try {
const response = await fetch('/api/retag/status');
const state = await response.json();
updateRetagProgressUI(state);
if (state.status === 'running' && !retagStatusInterval) {
startRetagPolling();
}
if (state.status !== 'running' && retagStatusInterval) {
clearInterval(retagStatusInterval);
retagStatusInterval = null;
if (state.status === 'finished') {
showToast('Retag completed successfully', 'success');
loadRetagStats();
} else if (state.status === 'error') {
showToast(`Retag error: ${state.error_message || 'Unknown error'}`, 'error');
}
}
} catch (e) {
// Ignore fetch errors during polling
}
}
function updateRetagStatusFromData(data) {
const prev = _lastToolStatus['retag'];
_lastToolStatus['retag'] = data.status;
if (prev !== undefined && data.status === prev && data.status !== 'running') return;
updateRetagProgressUI(data);
// Handle terminal state toasts (only on transition)
if (prev === 'running' || prev === undefined) {
if (data.status === 'finished') {
showToast('Retag completed successfully', 'success');
loadRetagStats();
} else if (data.status === 'error') {
showToast(`Retag error: ${data.error_message || 'Unknown error'}`, 'error');
}
}
}
function updateRetagProgressUI(state) {
const phaseLabel = document.getElementById('retag-phase-label');
const progressBar = document.getElementById('retag-progress-bar');
const progressLabel = document.getElementById('retag-progress-label');
const statusEl = document.getElementById('retag-stat-status');
if (phaseLabel) phaseLabel.textContent = state.phase || 'Ready';
if (progressBar) progressBar.style.width = `${state.progress || 0}%`;
if (progressLabel) {
progressLabel.textContent = `${state.processed || 0} / ${state.total_tracks || 0} tracks (${(state.progress || 0).toFixed(1)}%)`;
}
if (statusEl) {
statusEl.textContent = state.status === 'running' ? 'Running' : 'Idle';
}
// Color the progress bar red on error
if (progressBar) {
progressBar.style.backgroundColor = state.status === 'error' ? '#ff4444' : '';
}
}
/**
* Show inline delete confirmation for a retag group
*/
function showRetagDeleteConfirm(groupId) {
const area = document.getElementById(`retag-delete-area-${groupId}`);
if (!area) return;
area.innerHTML = `
Remove?
`;
}
function cancelRetagDeleteConfirm(groupId) {
const area = document.getElementById(`retag-delete-area-${groupId}`);
if (!area) return;
area.innerHTML = ``;
}
async function executeRetagGroupDelete(groupId) {
try {
const response = await fetch(`/api/retag/groups/${groupId}`, { method: 'DELETE' });
const data = await response.json();
if (data.success) {
const card = document.querySelector(`.retag-group-card[data-group-id="${groupId}"]`);
if (card) {
const section = card.closest('.retag-artist-section');
card.remove();
if (section && section.querySelectorAll('.retag-group-card').length === 0) {
section.remove();
}
}
loadRetagStats();
updateRetagBatchBar();
showToast('Group removed', 'success');
} else {
showToast('Failed to remove group', 'error');
}
} catch (e) {
showToast('Failed to remove group', 'error');
}
}
/**
* Update the retag batch action bar based on checkbox selection
*/
function updateRetagBatchBar() {
const checked = document.querySelectorAll('.retag-select-cb:checked');
const bar = document.getElementById('retag-batch-bar');
const countEl = document.getElementById('retag-batch-count');
if (!bar) return;
if (checked.length > 0) {
bar.style.display = 'flex';
countEl.textContent = `${checked.length} selected`;
} else {
bar.style.display = 'none';
}
}
/**
* Batch remove selected retag groups
*/
async function batchRemoveRetagGroups() {
const checked = document.querySelectorAll('.retag-select-cb:checked');
if (checked.length === 0) return;
const groupIds = Array.from(checked).map(cb => parseInt(cb.getAttribute('data-group-id')));
try {
const response = await fetch('/api/retag/groups/delete-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_ids: groupIds })
});
const data = await response.json();
if (data.success) {
showToast(`Removed ${data.removed} group${data.removed !== 1 ? 's' : ''}`, 'success');
openRetagModal(); // Refresh
} else {
showToast('Failed to remove groups', 'error');
}
} catch (e) {
showToast('Failed to remove groups', 'error');
}
}
/**
* Clear all retag groups โ inline confirm on the button itself
*/
function clearAllRetagGroups(btn) {
if (!btn) return;
if (btn.dataset.confirming === 'true') {
// Already confirming โ execute
btn.dataset.confirming = '';
btn.textContent = 'Clear All';
executeClearAllRetag();
return;
}
// First click โ show confirm state
btn.dataset.confirming = 'true';
btn.textContent = 'Confirm Clear?';
btn.style.background = 'rgba(255, 59, 48, 0.15)';
// Auto-reset after 3 seconds if not clicked again
setTimeout(() => {
if (btn.dataset.confirming === 'true') {
btn.dataset.confirming = '';
btn.textContent = 'Clear All';
btn.style.background = '';
}
}, 3000);
}
async function executeClearAllRetag() {
try {
const response = await fetch('/api/retag/groups/clear-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
showToast(`Cleared ${data.removed} group${data.removed !== 1 ? 's' : ''}`, 'success');
openRetagModal(); // Refresh
} else {
showToast('Failed to clear groups', 'error');
}
} catch (e) {
showToast('Failed to clear groups', 'error');
}
}
function stopWishlistCountPolling() {
if (wishlistCountInterval) {
clearInterval(wishlistCountInterval);
wishlistCountInterval = null;
}
}
function resetWishlistModalToIdleState() {
// Reset wishlist modal to idle state after background processing completes
const playlistId = 'wishlist';
const process = activeDownloadProcesses[playlistId];
if (process) {
console.log('๐ Resetting wishlist modal to idle state...');
// Reset button states
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) {
beginBtn.style.display = 'inline-block';
beginBtn.disabled = false;
beginBtn.textContent = 'Begin Analysis';
}
if (cancelBtn) {
cancelBtn.style.display = 'none';
}
// Show the force download toggle again
const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container');
if (forceToggleContainer) {
forceToggleContainer.style.display = 'flex';
}
// Reset progress displays
const analysisText = document.getElementById(`analysis-progress-text-${playlistId}`);
const analysisBar = document.getElementById(`analysis-progress-fill-${playlistId}`);
const downloadText = document.getElementById(`download-progress-text-${playlistId}`);
const downloadBar = document.getElementById(`download-progress-fill-${playlistId}`);
if (analysisText) analysisText.textContent = 'Ready to start';
if (analysisBar) analysisBar.style.width = '0%';
if (downloadText) downloadText.textContent = 'Waiting for analysis';
if (downloadBar) downloadBar.style.width = '0%';
// Reset all track rows to pending state
const trackRows = document.querySelectorAll(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index]`);
trackRows.forEach((row, index) => {
const matchCell = row.querySelector(`#match-${playlistId}-${index}`);
const downloadCell = row.querySelector(`#download-${playlistId}-${index}`);
const actionsCell = row.querySelector(`#actions-${playlistId}-${index}`);
if (matchCell) matchCell.textContent = '๐ Pending';
if (downloadCell) downloadCell.textContent = '-';
if (actionsCell) actionsCell.innerHTML = '-';
});
// Reset stats
const foundElement = document.getElementById(`stat-found-${playlistId}`);
const missingElement = document.getElementById(`stat-missing-${playlistId}`);
const downloadedElement = document.getElementById(`stat-downloaded-${playlistId}`);
if (foundElement) foundElement.textContent = '-';
if (missingElement) missingElement.textContent = '-';
if (downloadedElement) downloadedElement.textContent = '0';
// Reset process status
process.status = 'idle';
process.batchId = null;
if (process.poller) {
clearInterval(process.poller);
process.poller = null;
}
console.log('โ Wishlist modal fully reset to idle state');
} else {
console.log('โ ๏ธ No wishlist process found to reset');
}
}
let toolsPageState = { isInitialized: false };
async function initializeToolsPage() {
// Attach event listeners for tool buttons (idempotent โ getElementById returns null if already wired)
const updateButton = document.getElementById('db-update-button');
if (updateButton && !updateButton._toolsWired) {
updateButton.addEventListener('click', handleDbUpdateButtonClick);
updateButton._toolsWired = true;
}
const metadataButton = document.getElementById('metadata-update-button');
if (metadataButton && !metadataButton._toolsWired) {
metadataButton.addEventListener('click', handleMetadataUpdateButtonClick);
metadataButton._toolsWired = true;
}
const qualityScanButton = document.getElementById('quality-scan-button');
if (qualityScanButton && !qualityScanButton._toolsWired) {
qualityScanButton.addEventListener('click', handleQualityScanButtonClick);
qualityScanButton._toolsWired = true;
}
const duplicateCleanButton = document.getElementById('duplicate-clean-button');
if (duplicateCleanButton && !duplicateCleanButton._toolsWired) {
duplicateCleanButton.addEventListener('click', handleDuplicateCleanButtonClick);
duplicateCleanButton._toolsWired = true;
}
const retagOpenButton = document.getElementById('retag-open-button');
if (retagOpenButton && !retagOpenButton._toolsWired) {
retagOpenButton.addEventListener('click', openRetagModal);
retagOpenButton._toolsWired = true;
}
const mediaScanButton = document.getElementById('media-scan-button');
if (mediaScanButton && !mediaScanButton._toolsWired) {
mediaScanButton.addEventListener('click', handleMediaScanButtonClick);
mediaScanButton._toolsWired = true;
}
const backupNowButton = document.getElementById('backup-now-button');
if (backupNowButton && !backupNowButton._toolsWired) {
backupNowButton.addEventListener('click', handleBackupNowClick);
backupNowButton._toolsWired = true;
}
// Tool-specific init
await checkAndHideMetadataUpdaterForNonPlex();
await checkAndRestoreMetadataUpdateState();
await checkAndShowMediaScanForPlex();
loadBackupList();
initializeToolHelpButtons();
loadRetagStats();
checkRetagStatus();
await fetchAndUpdateDbStats();
loadDiscoveryPoolStats();
loadMetadataCacheStats();
// Start polling (cleared when navigating away via loadPageData preamble)
stopDbStatsPolling();
dbStatsInterval = setInterval(fetchAndUpdateDbStats, 10000);
// Check for ongoing operations
await checkAndUpdateDbProgress();
await checkAndUpdateQualityScanProgress();
await checkAndUpdateDuplicateCleanProgress();
// Initialize library maintenance section
updateRepairStatus();
switchRepairTab('jobs');
toolsPageState.isInitialized = true;
}
async function loadDashboardData() {
// Initial load of wishlist count
await updateWishlistCount();
// Start periodic refresh of wishlist count (every 10 seconds)
stopWishlistCountPolling(); // Ensure no duplicates
wishlistCountInterval = setInterval(updateWishlistCount, 10000);
// Initial load of service status, system statistics, and library status
await fetchAndUpdateServiceStatus();
await fetchAndUpdateSystemStats();
await fetchAndUpdateDbStats();
// Service status is already polled globally (line 311)
// System stats polling kept here (dashboard-specific)
setInterval(fetchAndUpdateSystemStats, 10000);
// Initial load of activity feed
await fetchAndUpdateActivityFeed();
// Start periodic refresh of activity feed (every 2 seconds for responsiveness)
setInterval(fetchAndUpdateActivityFeed, 2000);
// Start periodic toast checking (every 3 seconds)
setInterval(checkForActivityToasts, 3000);
// Check for any active download processes that need rehydration
await checkForActiveProcesses();
// Populate the Active Downloads dashboard section with any existing downloads
updateDashboardDownloads();
// Automatic wishlist processing now runs server-side
}
// --- Data Fetching and UI Updates ---
async function fetchAndUpdateDbStats() {
if (socketConnected) return; // WebSocket handles this
try {
const response = await fetch('/api/database/stats');
if (!response.ok) return;
const stats = await response.json();
// This function updates the stat cards in the top grid
updateDashboardStatCards(stats);
// This function updates the info within the DB Updater tool card
updateDbUpdaterCardInfo(stats);
} catch (error) {
console.warn('Could not fetch DB stats:', error);
}
}
function updateDashboardStatCards(stats) {
// Update the Library Status card on the dashboard
updateLibraryStatusCard(stats);
}
/**
* Smart Library Status card on the Dashboard.
* Shows different states: no server, empty library, healthy library, scanning.
*/
function updateLibraryStatusCard(dbStats) {
const card = document.getElementById('library-status-card');
if (!card) return;
const title = document.getElementById('library-status-title');
const subtitle = document.getElementById('library-status-subtitle');
const statsRow = document.getElementById('library-status-stats');
const scanBtn = document.getElementById('library-status-scan-btn');
const scanLabel = document.getElementById('library-status-scan-label');
const deepBtn = document.getElementById('library-status-deep-btn');
const progressDiv = document.getElementById('library-status-progress');
const messageDiv = document.getElementById('library-status-message');
const artists = dbStats ? (dbStats.artists || 0) : 0;
const albums = dbStats ? (dbStats.albums || 0) : 0;
const tracks = dbStats ? (dbStats.tracks || 0) : 0;
const sizeMb = dbStats ? (dbStats.database_size_mb || 0) : 0;
const lastUpdate = dbStats ? dbStats.last_update : null;
const serverSource = dbStats ? dbStats.server_source : null;
// Check if a scan is in progress
const isScanning = window._libraryStatusScanning || false;
// Determine state
const serverConnected = _lastServiceStatus && _lastServiceStatus.media_server && _lastServiceStatus.media_server.connected;
const serverType = _lastServiceStatus && _lastServiceStatus.active_media_server;
const hasData = tracks > 0;
const hasServer = !!serverType && serverType !== 'none';
// Reset classes
card.className = 'library-status-card';
if (isScanning) {
// State: Scanning
card.classList.add('scanning');
if (title) title.textContent = 'Library Scan';
if (subtitle) subtitle.textContent = 'Updating library database...';
if (scanBtn) {
scanBtn.style.display = '';
scanBtn.classList.add('scanning');
scanLabel.textContent = 'Stop';
scanBtn.disabled = false;
}
if (deepBtn) deepBtn.style.display = 'none';
if (statsRow) statsRow.style.display = hasData ? '' : 'none';
if (progressDiv) progressDiv.style.display = '';
if (messageDiv) messageDiv.style.display = 'none';
} else if (!hasServer) {
// State: No server configured
card.classList.add('needs-setup');
if (title) title.textContent = 'No Media Server';
if (subtitle) subtitle.textContent = 'Connect a server to get started';
if (scanBtn) scanBtn.style.display = 'none';
if (deepBtn) deepBtn.style.display = 'none';
if (statsRow) statsRow.style.display = 'none';
if (progressDiv) progressDiv.style.display = 'none';
if (messageDiv) {
messageDiv.style.display = '';
messageDiv.innerHTML = 'SoulSync needs a media server to manage your library. '
+ 'Go to Settings '
+ 'to connect Plex, Jellyfin, or Navidrome.';
}
} else if (!serverConnected) {
// State: Server configured but not connected
card.classList.add('needs-setup');
const serverName = _capitalize(serverType);
if (title) title.textContent = `${serverName} โ Disconnected`;
if (subtitle) subtitle.textContent = 'Cannot reach your media server';
if (scanBtn) scanBtn.style.display = 'none';
if (deepBtn) deepBtn.style.display = 'none';
if (statsRow) statsRow.style.display = 'none';
if (progressDiv) progressDiv.style.display = 'none';
if (messageDiv) {
messageDiv.style.display = '';
messageDiv.innerHTML = `Your ${serverName} server is configured but not responding. `
+ 'Check that it\'s running and the connection details are correct in '
+ 'Settings.';
}
} else if (!hasData) {
// State: Server connected but library is empty
card.classList.add('empty-library');
const serverName = _capitalize(serverType);
if (title) title.textContent = `${serverName} Connected`;
if (subtitle) subtitle.textContent = 'Library database is empty';
if (scanBtn) {
scanBtn.style.display = '';
scanBtn.classList.remove('scanning');
scanLabel.textContent = 'Scan Now';
scanBtn.disabled = false;
}
if (deepBtn) deepBtn.style.display = 'none';
if (statsRow) statsRow.style.display = 'none';
if (progressDiv) progressDiv.style.display = 'none';
if (messageDiv) {
messageDiv.style.display = '';
messageDiv.innerHTML = 'Your server is connected but SoulSync hasn\'t imported your library yet. '
+ 'Click Scan Now to pull your artists, albums, and tracks into SoulSync.';
}
} else {
// State: Healthy library with data
card.classList.add('has-data');
const serverName = _capitalize(serverType);
let lastRefreshText = 'Never';
if (lastUpdate) {
const d = new Date(lastUpdate);
if (!isNaN(d.getTime())) {
lastRefreshText = typeof _formatTimeAgo === 'function' ? _formatTimeAgo(d) : d.toLocaleDateString();
}
}
if (title) title.textContent = `${serverName} Library`;
if (subtitle) subtitle.textContent = `Last refreshed ${lastRefreshText}`;
if (scanBtn) {
scanBtn.style.display = '';
scanBtn.classList.remove('scanning');
scanLabel.textContent = 'Refresh';
scanBtn.disabled = false;
}
if (deepBtn) deepBtn.style.display = '';
if (statsRow) {
statsRow.style.display = '';
document.getElementById('library-status-artists').textContent = artists.toLocaleString();
document.getElementById('library-status-albums').textContent = albums.toLocaleString();
document.getElementById('library-status-tracks').textContent = tracks.toLocaleString();
document.getElementById('library-status-size').textContent = sizeMb < 1 ? `${Math.round(sizeMb * 1024)} KB` : `${sizeMb.toFixed(1)} MB`;
}
if (progressDiv) progressDiv.style.display = 'none';
if (messageDiv) messageDiv.style.display = 'none';
}
}
// Track last service status for library card
let _lastServiceStatus = null;
let _isSoulsyncStandalone = false; // Global flag: true when no media server (sync buttons hidden)
const _origFetchServiceStatus = typeof fetchAndUpdateServiceStatus === 'function' ? fetchAndUpdateServiceStatus : null;
function _capitalize(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; }
/**
* Dashboard library scan button handler โ triggers incremental DB update.
*/
async function dashboardLibraryScan(fullRefresh = false) {
const scanBtn = document.getElementById('library-status-scan-btn');
const scanLabel = document.getElementById('library-status-scan-label');
// If already scanning, stop it
if (window._libraryStatusScanning) {
try {
await fetch('/api/database/update/stop', { method: 'POST' });
window._libraryStatusScanning = false;
showToast('Library scan stopped', 'info');
// Refresh the card
try {
const r = await fetch('/api/database/stats');
if (r.ok) updateLibraryStatusCard(await r.json());
} catch (e) {}
} catch (e) {
showToast('Failed to stop scan', 'error');
}
return;
}
// Start scan
try {
window._libraryStatusScanning = true;
updateLibraryStatusCard(null); // Update to scanning state
const response = await fetch('/api/database/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ full_refresh: fullRefresh })
});
const data = await response.json();
if (!data.success) {
window._libraryStatusScanning = false;
showToast(data.error || 'Failed to start scan', 'error');
return;
}
showToast('Library scan started', 'success');
// Poll for progress
const pollInterval = setInterval(async () => {
try {
const statusResp = await fetch('/api/database/update/status');
if (!statusResp.ok) return;
const status = await statusResp.json();
const phase = document.getElementById('library-status-phase');
const barFill = document.getElementById('library-status-bar-fill');
const detail = document.getElementById('library-status-progress-detail');
if (phase) phase.textContent = status.phase || 'Scanning...';
if (barFill) barFill.style.width = `${status.progress || 0}%`;
if (detail && status.processed !== undefined) {
detail.textContent = `${status.processed} / ${status.total || '?'}`;
}
if (status.status === 'completed' || status.status === 'finished' || status.status === 'error' || status.status === 'idle') {
clearInterval(pollInterval);
window._libraryStatusScanning = false;
if (status.status === 'completed' || status.status === 'finished') {
showToast('Library scan complete', 'success');
} else if (status.status === 'error') {
showToast(`Scan error: ${status.error_message || 'Unknown'}`, 'error');
}
// Refresh stats
try {
const r = await fetch('/api/database/stats');
if (r.ok) updateLibraryStatusCard(await r.json());
} catch (e) {}
}
} catch (e) {
clearInterval(pollInterval);
window._libraryStatusScanning = false;
}
}, 2000);
} catch (e) {
window._libraryStatusScanning = false;
showToast(`Scan failed: ${e.message}`, 'error');
}
}
/**
* Dashboard deep scan โ finds new tracks, removes stale ones, preserves enrichment data.
*/
async function dashboardLibraryDeepScan() {
if (window._libraryStatusScanning) {
showToast('A scan is already running', 'warning');
return;
}
if (!await showConfirmDialog({
title: 'Deep Scan Library',
message: 'A deep scan re-checks every track in your media server library.\n\n' +
'โข Adds any new tracks that were missed\n' +
'โข Removes tracks no longer on your server\n' +
'โข Preserves all existing metadata and enrichment data\n\n' +
'This may take a while for large libraries. Continue?',
})) return;
// Use the same scan flow as dashboardLibraryScan but with deep_scan flag
try {
window._libraryStatusScanning = true;
updateLibraryStatusCard(null);
const response = await fetch('/api/database/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deep_scan: true })
});
const data = await response.json();
if (!data.success) {
window._libraryStatusScanning = false;
showToast(data.error || 'Failed to start deep scan', 'error');
try { const r = await fetch('/api/database/stats'); if (r.ok) updateLibraryStatusCard(await r.json()); } catch (e) {}
return;
}
showToast('Deep scan started โ this may take a while', 'success');
const pollInterval = setInterval(async () => {
try {
const statusResp = await fetch('/api/database/update/status');
if (!statusResp.ok) return;
const status = await statusResp.json();
const phase = document.getElementById('library-status-phase');
const barFill = document.getElementById('library-status-bar-fill');
const detail = document.getElementById('library-status-progress-detail');
if (phase) phase.textContent = status.phase || 'Deep scanning...';
if (barFill) barFill.style.width = `${status.progress || 0}%`;
if (detail && status.processed !== undefined) {
detail.textContent = `${status.processed} / ${status.total || '?'}`;
}
if (status.status === 'completed' || status.status === 'finished' || status.status === 'error' || status.status === 'idle') {
clearInterval(pollInterval);
window._libraryStatusScanning = false;
if (status.status === 'completed' || status.status === 'finished') {
showToast('Deep scan complete', 'success');
} else if (status.status === 'error') {
showToast(`Deep scan error: ${status.error_message || 'Unknown'}`, 'error');
}
try { const r = await fetch('/api/database/stats'); if (r.ok) updateLibraryStatusCard(await r.json()); } catch (e) {}
}
} catch (e) {
clearInterval(pollInterval);
window._libraryStatusScanning = false;
}
}, 2000);
} catch (e) {
window._libraryStatusScanning = false;
showToast(`Deep scan failed: ${e.message}`, 'error');
}
}
/**
* Update the Active Downloads section on the dashboard.
* Called from artist, search, and discover update points (event-driven, no polling).
*/
function updateDashboardDownloads() {
const section = document.getElementById('dashboard-active-downloads-section');
const container = document.getElementById('dashboard-downloads-container');
if (!section || !container) return;
// Collect active entries from each source
const activeArtists = Object.keys(artistDownloadBubbles).filter(id =>
artistDownloadBubbles[id].downloads.length > 0
);
const activeSearch = Object.keys(searchDownloadBubbles).filter(name =>
searchDownloadBubbles[name].downloads.length > 0
);
const activeDiscover = Object.keys(discoverDownloads);
const activeBeatport = Object.keys(beatportDownloadBubbles).filter(key =>
beatportDownloadBubbles[key].downloads.length > 0
);
const totalCount = activeArtists.length + activeSearch.length + activeDiscover.length + activeBeatport.length;
if (totalCount === 0) {
section.style.display = 'none';
container.innerHTML = '';
return;
}
section.style.display = '';
let html = '';
// --- Artists group ---
if (activeArtists.length > 0) {
html += `
`;
}
case 'discovered':
case 'downloading':
case 'download_complete':
// Only show buttons if we actually have discovery data
if (!hasDiscoveryResults) {
return `
โ ๏ธ No discovery results available. Try starting discovery again.
`;
}
let buttons = '';
// Only show sync button if there are Spotify matches (and not standalone mode)
if (hasSpotifyMatches && !_isSoulsyncStandalone) {
if (isListenBrainz) {
buttons += ``;
} else if (isTidal) {
buttons += ``;
} else if (isDeezer) {
buttons += ``;
} else if (isSpotifyPublic) {
buttons += ``;
} else if (isBeatport) {
buttons += ``;
} else {
buttons += ``;
}
}
// Only show download button if we have matches or a converted playlist ID
if (hasSpotifyMatches || hasConvertedPlaylistId) {
if (isListenBrainz) {
buttons += ``;
} else if (isTidal) {
buttons += ``;
} else if (isDeezer) {
buttons += ``;
} else if (isSpotifyPublic) {
buttons += ``;
} else if (isBeatport) {
buttons += ``;
} else {
buttons += ``;
}
}
// Retry Failed button for mirrored playlists
if (state && state.is_mirrored_playlist) {
const results = state.discovery_results || state.discoveryResults || [];
const failedCount = results.filter(r => r.status_class !== 'found').length;
if (failedCount > 0) {
buttons += ``;
}
}
// Rediscover button โ reset and re-run discovery (only for sources with reset endpoints)
if (isBeatport) {
buttons += ``;
} else if (!isListenBrainz && !isTidal && !isDeezer && !isSpotifyPublic) {
buttons += ``;
}
// Wing It button โ available in discovered phase
buttons += ` `;
if (!buttons || buttons.trim().startsWith('