// 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
// --- 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 = {};
// --- 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: ['websocket', 'polling'],
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('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: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) {
// 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 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);
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') {
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);
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';
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);
});
}
}
// Bootstrap accent from localStorage instantly (prevents default-color flash)
(function() {
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';
}
function showProfilePicker(profiles, canCancel = false) {
const overlay = document.getElementById('profile-picker-overlay');
const grid = document.getElementById('profile-picker-grid');
const actions = document.getElementById('profile-picker-actions');
grid.innerHTML = '';
profiles.forEach(p => {
const card = document.createElement('div');
card.className = 'profile-picker-card';
const avatarEl = document.createElement('div');
renderProfileAvatar(avatarEl, p);
card.appendChild(avatarEl);
const nameEl = document.createElement('span');
nameEl.className = 'profile-name';
nameEl.textContent = p.name;
card.appendChild(nameEl);
if (p.is_admin) {
const badge = document.createElement('span');
badge.className = 'profile-badge';
badge.textContent = 'Admin';
card.appendChild(badge);
}
card.onclick = () => handleProfileClick(p);
grid.appendChild(card);
});
// Show actions: admin sees "Manage Profiles", non-admin sees "My Profile" (when they have a profile selected)
const isAdmin = currentProfile ? currentProfile.is_admin : false;
const manageBtn = document.getElementById('manage-profiles-btn');
if (isAdmin) {
actions.style.display = '';
if (manageBtn) {
manageBtn.textContent = 'Manage Profiles';
// Reset onclick to admin handler (initProfileManagement sets this, but re-affirm here)
manageBtn.onclick = () => {
document.getElementById('profile-manage-panel').style.display = 'flex';
loadProfileManageList();
};
}
} else if (currentProfile && canCancel) {
// Non-admin with an active profile: show "My Profile" to edit own settings
actions.style.display = '';
if (manageBtn) {
manageBtn.textContent = 'My Profile';
manageBtn.onclick = () => showSelfEditForm();
}
} else {
actions.style.display = 'none';
}
// Show/remove cancel button when opened from sidebar indicator
let cancelBtn = overlay.querySelector('.profile-picker-cancel');
if (cancelBtn) cancelBtn.remove();
if (canCancel) {
cancelBtn = document.createElement('button');
cancelBtn.className = 'profile-picker-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = () => hideProfilePicker();
actions.parentElement.appendChild(cancelBtn);
}
overlay.style.display = 'flex';
document.querySelector('.main-container').style.display = 'none';
}
async function handleProfileClick(profile) {
// Fetch profile count — PIN only matters with multiple profiles
let profileCount = 1;
try {
const r = await fetch('/api/profiles');
const d = await r.json();
profileCount = (d.profiles || []).length;
} catch (e) {}
if (profile.has_pin && profileCount > 1) {
showPinDialog(profile);
} else {
const wasSwitching = !!currentProfile;
await selectProfile(profile.id);
if (wasSwitching) {
window.location.reload();
return;
}
hideProfilePicker();
initApp();
}
}
function showPinDialog(profile) {
const dialog = document.getElementById('profile-pin-dialog');
const avatar = document.getElementById('profile-pin-avatar');
const nameEl = document.getElementById('profile-pin-name');
const input = document.getElementById('profile-pin-input');
const errorEl = document.getElementById('profile-pin-error');
renderProfileAvatar(avatar, profile);
nameEl.textContent = profile.name;
input.value = '';
errorEl.style.display = 'none';
dialog.style.display = 'flex';
setTimeout(() => input.focus(), 100);
const submit = document.getElementById('profile-pin-submit');
const cancel = document.getElementById('profile-pin-cancel');
const wasSwitching = !!currentProfile;
const handleSubmit = async () => {
const pin = input.value;
if (!pin) return;
try {
const res = await fetch('/api/profiles/select', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profile.id, pin })
});
const data = await res.json();
if (data.success) {
cleanup();
if (wasSwitching) {
window.location.reload();
return;
}
currentProfile = data.profile;
dialog.style.display = 'none';
hideProfilePicker();
updateProfileIndicator();
initApp();
return;
} else {
errorEl.textContent = data.error || 'Invalid PIN';
errorEl.style.display = '';
input.value = '';
input.focus();
}
} catch (e) {
errorEl.textContent = 'Connection error';
errorEl.style.display = '';
}
cleanup();
};
const handleCancel = () => {
dialog.style.display = 'none';
cleanup();
};
const handleKeydown = (e) => {
if (e.key === 'Enter') handleSubmit();
if (e.key === 'Escape') handleCancel();
};
const cleanup = () => {
submit.removeEventListener('click', handleSubmit);
cancel.removeEventListener('click', handleCancel);
input.removeEventListener('keydown', handleKeydown);
};
submit.addEventListener('click', handleSubmit);
cancel.addEventListener('click', handleCancel);
input.addEventListener('keydown', handleKeydown);
}
async function selectProfile(profileId) {
try {
const oldProfileId = currentProfile ? currentProfile.id : null;
const res = await fetch('/api/profiles/select', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profileId })
});
const data = await res.json();
if (data.success) {
currentProfile = data.profile;
updateProfileIndicator();
// Join profile-scoped WebSocket room for watchlist/wishlist count updates
if (socket && socket.connected) {
socket.emit('profile:join', { profile_id: profileId, old_profile_id: oldProfileId });
}
// Invalidate ListenBrainz cache on profile switch (each profile has their own playlists)
_invalidateListenBrainzCache();
}
return data.success;
} catch (e) {
console.error('Error selecting profile:', e);
return false;
}
}
function hideProfilePicker() {
document.getElementById('profile-picker-overlay').style.display = 'none';
document.querySelector('.main-container').style.display = 'flex';
}
function updateProfileIndicator() {
const indicator = document.getElementById('profile-indicator');
if (!currentProfile || !indicator) return;
const avatar = document.getElementById('profile-indicator-avatar');
const name = document.getElementById('profile-indicator-name');
renderProfileAvatar(avatar, currentProfile);
name.textContent = currentProfile.name;
indicator.style.display = 'flex';
indicator.onclick = async () => {
const res = await fetch('/api/profiles');
const data = await res.json();
if (data.profiles && data.profiles.length > 0) {
showProfilePicker(data.profiles, true);
}
};
// Filter sidebar pages based on profile permissions
document.querySelectorAll('.nav-button[data-page]').forEach(btn => {
const page = btn.getAttribute('data-page');
if (page === 'hydrabase') return; // Managed by dev mode toggle
if (page === 'settings') {
// Settings always gated by is_admin
btn.style.display = currentProfile.is_admin ? '' : 'none';
} else if (page === 'help' || page === 'issues') {
btn.style.display = ''; // Always visible
} else if (currentProfile.id === 1) {
btn.style.display = ''; // Root admin sees all
} else {
const ap = currentProfile.allowed_pages;
btn.style.display = (!ap || ap.includes(page)) ? '' : 'none';
}
});
// Toggle download capability
if (canDownload()) {
document.body.classList.remove('downloads-disabled');
} else {
document.body.classList.add('downloads-disabled');
}
}
// =====================
// PERSONAL SETTINGS MODAL
// =====================
async function openPersonalSettings() {
const overlay = document.getElementById('personal-settings-overlay');
if (!overlay) return;
overlay.style.display = 'flex';
const body = document.getElementById('personal-settings-body');
body.innerHTML = '
${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();
}
}
// ── 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();
}
}
// ===== 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 testDeezerDownloadConnection() {
const statusEl = document.getElementById('deezer-download-status');
if (!statusEl) return;
statusEl.textContent = 'Checking...';
statusEl.style.color = '#aaa';
try {
// Save the ARL first so the backend can use it
const arl = document.getElementById('deezer-download-arl')?.value || '';
if (!arl) {
statusEl.textContent = 'No ARL token provided';
statusEl.style.color = '#ff9800';
return;
}
const resp = await fetch('/api/deezer-download/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ arl }),
});
const data = await resp.json();
if (data.success) {
statusEl.textContent = `Connected as ${data.user || 'Unknown'} (${data.tier || 'Free'})`;
statusEl.style.color = '#4caf50';
} else {
statusEl.textContent = data.error || 'Authentication failed';
statusEl.style.color = '#f44336';
}
} catch (e) {
statusEl.textContent = 'Connection error';
statusEl.style.color = '#f44336';
}
}
async function checkTidalDownloadAuthStatus() {
const statusEl = document.getElementById('tidal-download-auth-status');
const btn = document.getElementById('tidal-download-auth-btn');
try {
const resp = await fetch('/api/tidal/download/auth/status');
const data = await resp.json();
if (data.authenticated) {
statusEl.textContent = 'Authenticated';
statusEl.style.color = '#4caf50';
btn.textContent = 'Re-link Tidal Account';
} else {
statusEl.textContent = 'Not authenticated';
statusEl.style.color = '#ff9800';
btn.textContent = 'Link Tidal Account';
}
} catch (e) {
statusEl.textContent = '';
}
}
let _tidalAuthPollTimer = null;
async function startTidalDownloadAuth() {
const btn = document.getElementById('tidal-download-auth-btn');
const statusEl = document.getElementById('tidal-download-auth-status');
const codeEl = document.getElementById('tidal-download-auth-code');
btn.disabled = true;
btn.textContent = 'Starting...';
statusEl.textContent = '';
try {
const resp = await fetch('/api/tidal/download/auth/start', { method: 'POST' });
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || 'Failed to start auth');
}
// Show the link/code to the user
const uri = data.verification_uri || '';
const code = data.user_code || '';
codeEl.style.display = 'block';
codeEl.innerHTML = `Go to ${uri} and enter code: ${code}`;
btn.textContent = 'Waiting for approval...';
statusEl.textContent = 'Waiting...';
statusEl.style.color = '#ff9800';
// Poll for completion
if (_tidalAuthPollTimer) clearInterval(_tidalAuthPollTimer);
_tidalAuthPollTimer = setInterval(async () => {
try {
const checkResp = await fetch('/api/tidal/download/auth/check');
const checkData = await checkResp.json();
if (checkData.status === 'completed') {
clearInterval(_tidalAuthPollTimer);
_tidalAuthPollTimer = null;
codeEl.style.display = 'none';
statusEl.textContent = 'Authenticated';
statusEl.style.color = '#4caf50';
btn.disabled = false;
btn.textContent = 'Re-link Tidal Account';
showToast('Tidal download account linked successfully', 'success');
} else if (checkData.status === 'error') {
clearInterval(_tidalAuthPollTimer);
_tidalAuthPollTimer = null;
codeEl.style.display = 'none';
statusEl.textContent = 'Auth failed';
statusEl.style.color = '#f44336';
btn.disabled = false;
btn.textContent = 'Link Tidal Account';
showToast('Tidal auth failed: ' + (checkData.message || 'Unknown error'), 'error');
}
// status === 'pending' — keep polling
} catch (pollErr) {
console.error('Tidal auth poll error:', pollErr);
}
}, 3000);
} catch (error) {
console.error('Tidal download auth error:', error);
showToast('Failed to start Tidal auth: ' + error.message, 'error');
btn.disabled = false;
btn.textContent = 'Link Tidal Account';
codeEl.style.display = 'none';
}
}
// ===============================
// QOBUZ AUTH FUNCTIONS
// ===============================
async function checkQobuzAuthStatus() {
try {
const resp = await fetch('/api/qobuz/auth/status');
const data = await resp.json();
// Update downloads tab section
const formEl = document.getElementById('qobuz-auth-form');
const loggedInEl = document.getElementById('qobuz-auth-logged-in');
const userInfoEl = document.getElementById('qobuz-auth-user-info');
// Update connections tab section
const connFormEl = document.getElementById('qobuz-connection-form');
const connLoggedInEl = document.getElementById('qobuz-connection-logged-in');
const connUserInfoEl = document.getElementById('qobuz-connection-user-info');
if (data.authenticated) {
const user = data.user || {};
const label = `Connected: ${user.display_name || 'Qobuz User'} (${user.subscription || 'Active'})`;
if (userInfoEl) { userInfoEl.textContent = label; }
if (loggedInEl) loggedInEl.style.display = 'flex';
if (formEl) formEl.style.display = 'none';
if (connUserInfoEl) { connUserInfoEl.textContent = label; }
if (connLoggedInEl) connLoggedInEl.style.display = 'flex';
if (connFormEl) connFormEl.style.display = 'none';
} else {
if (loggedInEl) loggedInEl.style.display = 'none';
if (formEl) formEl.style.display = 'block';
if (connLoggedInEl) connLoggedInEl.style.display = 'none';
if (connFormEl) connFormEl.style.display = 'block';
}
} catch (e) {
console.error('Qobuz auth status check failed:', e);
}
}
async function loginQobuzFromConnections() {
const btn = document.getElementById('qobuz-connection-login-btn');
const statusEl = document.getElementById('qobuz-connection-status');
const email = document.getElementById('qobuz-connection-email').value.trim();
const password = document.getElementById('qobuz-connection-password').value;
if (!email || !password) {
showToast('Please enter your Qobuz email and password', 'warning');
return;
}
btn.disabled = true;
btn.textContent = 'Connecting...';
statusEl.textContent = '';
try {
const resp = await fetch('/api/qobuz/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await resp.json();
if (data.success) {
showToast('Qobuz connected successfully!', 'success');
document.getElementById('qobuz-connection-password').value = '';
checkQobuzAuthStatus();
} else {
statusEl.textContent = data.error || 'Login failed';
statusEl.style.color = '#ff5555';
showToast(data.error || 'Qobuz login failed', 'error');
}
} catch (error) {
console.error('Qobuz login error:', error);
statusEl.textContent = 'Connection error';
statusEl.style.color = '#ff5555';
showToast('Failed to connect to Qobuz', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Connect Qobuz';
}
}
async function loginQobuz() {
const btn = document.getElementById('qobuz-login-btn');
const statusEl = document.getElementById('qobuz-auth-status');
const email = document.getElementById('qobuz-email').value.trim();
const password = document.getElementById('qobuz-password').value;
if (!email || !password) {
showToast('Please enter your Qobuz email and password', 'warning');
return;
}
btn.disabled = true;
btn.textContent = 'Connecting...';
statusEl.textContent = '';
try {
const resp = await fetch('/api/qobuz/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await resp.json();
if (data.success) {
showToast('Qobuz connected successfully!', 'success');
// Clear password field
document.getElementById('qobuz-password').value = '';
checkQobuzAuthStatus();
} else {
statusEl.textContent = data.error || 'Login failed';
statusEl.style.color = '#ff5555';
showToast(data.error || 'Qobuz login failed', 'error');
}
} catch (error) {
console.error('Qobuz login error:', error);
statusEl.textContent = 'Connection error';
statusEl.style.color = '#ff5555';
showToast('Failed to connect to Qobuz', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Connect Qobuz';
}
}
async function logoutQobuz() {
try {
await fetch('/api/qobuz/auth/logout', { method: 'POST' });
showToast('Qobuz disconnected', 'success');
checkQobuzAuthStatus();
} catch (e) {
console.error('Qobuz logout error:', e);
}
}
const PATH_INPUT_IDS = {
download: 'download-path',
transfer: 'transfer-path',
staging: 'staging-path'
};
function togglePathLock(pathType, btn) {
const input = document.getElementById(PATH_INPUT_IDS[pathType]);
if (!input) return;
const isLocked = input.hasAttribute('readonly');
if (isLocked) {
input.removeAttribute('readonly');
input.focus();
btn.textContent = 'Lock';
btn.classList.remove('locked');
} else {
input.setAttribute('readonly', '');
btn.textContent = 'Unlock';
btn.classList.add('locked');
}
}
// ===============================
// SEARCH FUNCTIONALITY
// ===============================
function initializeSearch() {
// --- FIX: Corrected the element IDs to match the HTML ---
const searchInput = document.getElementById('downloads-search-input');
const searchButton = document.getElementById('downloads-search-btn');
// Add this line to get the cancel button
const cancelButton = document.getElementById('downloads-cancel-btn');
if (searchButton && searchInput) {
searchButton.addEventListener('click', performDownloadsSearch);
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') performDownloadsSearch();
});
}
// Add this event listener for the cancel button
if (cancelButton) {
cancelButton.addEventListener('click', () => {
if (searchAbortController) {
searchAbortController.abort(); // This cancels the fetch request
console.log("Search cancelled by user.");
}
});
}
}
// ===============================
// SEARCH MODE TOGGLE
// ===============================
let searchModeToggleInitialized = false;
function initializeSearchModeToggle() {
// Only initialize once to prevent duplicate event listeners
if (searchModeToggleInitialized) {
console.log('Search mode toggle already initialized, skipping...');
return;
}
const toggleContainer = document.querySelector('.search-mode-toggle');
const modeBtns = document.querySelectorAll('.search-mode-btn');
const basicSection = document.getElementById('basic-search-section');
const enhancedSection = document.getElementById('enhanced-search-section');
if (!toggleContainer || !modeBtns.length || !basicSection || !enhancedSection) {
console.warn('Search mode toggle elements not found');
return;
}
searchModeToggleInitialized = true;
console.log('✅ Initializing search mode toggle (first time only)');
modeBtns.forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode;
// Update button active states
modeBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Update toggle slider position
toggleContainer.setAttribute('data-active', mode);
// Toggle sections
if (mode === 'basic') {
basicSection.classList.add('active');
enhancedSection.classList.remove('active');
console.log('Switched to basic search mode');
} else {
basicSection.classList.remove('active');
enhancedSection.classList.add('active');
console.log('Switched to enhanced search mode');
}
});
});
// Initialize enhanced search
const enhancedInput = document.getElementById('enhanced-search-input');
const enhancedSearchBtn = document.getElementById('enhanced-search-btn');
const enhancedCancelBtn = document.getElementById('enhanced-cancel-btn');
const enhancedDropdown = document.getElementById('enhanced-dropdown');
const loadingState = document.getElementById('enhanced-loading');
const emptyState = document.getElementById('enhanced-empty');
const resultsContainer = document.getElementById('enhanced-results-container');
let debounceTimer = null;
let abortController = null;
// Multi-source search state
let _enhancedSearchData = null; // Full response with all sources
let _activeSearchSource = null; // Currently displayed source tab
let _altSourceController = null; // AbortController for alternate source fetches
const SOURCE_LABELS = {
spotify: { text: 'Spotify', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify' },
itunes: { text: 'Apple Music', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes' },
deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' },
hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' },
};
// Live search with debouncing
if (enhancedInput) {
enhancedInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
// Show/hide cancel button
if (enhancedCancelBtn) {
enhancedCancelBtn.classList.toggle('hidden', query.length === 0);
}
// Clear debounce timer
clearTimeout(debounceTimer);
// Hide dropdown if query too short
if (query.length < 2) {
hideDropdown();
return;
}
// Debounce search
debounceTimer = setTimeout(() => {
performEnhancedSearch(query);
}, 300);
});
enhancedInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
const query = e.target.value.trim();
if (query.length >= 2) {
clearTimeout(debounceTimer);
performEnhancedSearch(query);
}
}
});
}
if (enhancedSearchBtn) {
enhancedSearchBtn.addEventListener('click', (e) => {
// Prevent click from bubbling to document (which would close the dropdown)
e.stopPropagation();
// Get fresh references (in case we navigated away and back)
const dropdown = document.getElementById('enhanced-dropdown');
const results = document.getElementById('enhanced-results-container');
if (!dropdown) return;
// Toggle the dropdown visibility to show/hide previous search results
if (dropdown.classList.contains('hidden')) {
// Check if there are results to show by looking for actual content
const hasResults = results &&
!results.classList.contains('hidden') &&
results.children.length > 0;
if (hasResults) {
showDropdown();
} else {
showToast('No previous results to show. Type to search!', 'info');
}
} else {
hideDropdown();
}
});
}
if (enhancedCancelBtn) {
enhancedCancelBtn.addEventListener('click', () => {
enhancedInput.value = '';
enhancedCancelBtn.classList.add('hidden');
hideDropdown();
});
}
// Close button inside dropdown (mobile)
const dropdownCloseBtn = document.getElementById('enhanced-dropdown-close');
if (dropdownCloseBtn) {
dropdownCloseBtn.addEventListener('click', (e) => {
e.stopPropagation();
hideDropdown();
});
}
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
const dropdown = document.getElementById('enhanced-dropdown');
if (dropdown && !dropdown.classList.contains('hidden')) {
const isClickInside = e.target.closest('.enhanced-search-input-wrapper');
if (!isClickInside) {
hideDropdown();
}
}
});
async function performEnhancedSearch(query) {
console.log('Enhanced search:', query);
// Show loading state with correct source name
showDropdown();
const loadingText = document.getElementById('enhanced-loading-text');
if (loadingText) {
loadingText.textContent = `Searching across ${currentMusicSourceName} and your library...`;
}
loadingState.classList.remove('hidden');
emptyState.classList.add('hidden');
resultsContainer.classList.add('hidden');
// Abort previous requests (primary + alternates)
if (abortController) {
abortController.abort();
}
if (_altSourceController) {
_altSourceController.abort();
}
abortController = new AbortController();
_altSourceController = new AbortController();
// Initialize multi-source state early so alternate fetches can write to it
_enhancedSearchData = { db_artists: [], primary_source: null, sources: {} };
// Fire ALL source fetches immediately in parallel with the primary endpoint.
// Don't guess which is primary — the main endpoint response will tell us.
// If an alternate duplicates the primary, it just overwrites with same data.
for (const srcName of ['spotify', 'itunes', 'deezer', 'hydrabase']) {
_fetchAlternateSource(srcName, query);
}
try {
const response = await fetch('/api/enhanced-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
signal: abortController.signal
});
if (!response.ok) throw new Error('Search failed');
const data = await response.json();
console.log('Enhanced results:', data);
// Store multi-source state
const primarySource = data.primary_source || data.metadata_source || 'spotify';
_activeSearchSource = primarySource;
_enhancedSearchData = _enhancedSearchData || {};
_enhancedSearchData.db_artists = data.db_artists;
_enhancedSearchData.primary_source = primarySource;
if (!_enhancedSearchData.sources) _enhancedSearchData.sources = {};
_enhancedSearchData.sources[primarySource] = {
artists: data.spotify_artists || [],
albums: data.spotify_albums || [],
tracks: data.spotify_tracks || [],
available: true,
};
// Calculate total from primary source
const total = (data.db_artists?.length || 0) +
(data.spotify_artists?.length || 0) +
(data.spotify_albums?.length || 0) +
(data.spotify_tracks?.length || 0);
// Hide loading
loadingState.classList.add('hidden');
if (total === 0) {
emptyState.classList.remove('hidden');
} else {
renderSourceTabs(_enhancedSearchData);
renderDropdownResults(data);
resultsContainer.classList.remove('hidden');
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Enhanced search error:', error);
loadingState.classList.add('hidden');
emptyState.classList.remove('hidden');
}
}
}
function renderDropdownResults(data) {
// Determine source badge from active tab (not just primary)
const displaySource = _activeSearchSource || data.metadata_source || 'spotify';
const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify;
const sourceBadge = { text: sourceInfo.text, class: sourceInfo.badgeClass };
// Render DB Artists
renderCompactSection(
'enh-db-artists-section',
'enh-db-artists-list',
'enh-db-artists-count',
data.db_artists || [],
(artist) => ({
image: artist.image_url,
placeholder: '📚',
name: artist.name,
meta: 'In Your Library',
badge: { text: 'Library', class: 'enh-badge-library' },
onClick: () => {
console.log(`🎵 Opening library artist detail: ${artist.name} (ID: ${artist.id})`);
hideDropdown();
navigateToArtistDetail(artist.id, artist.name);
}
})
);
// Render Artists (source-aware badge)
renderCompactSection(
'enh-spotify-artists-section',
'enh-spotify-artists-list',
'enh-spotify-artists-count',
data.spotify_artists || [],
(artist) => ({
image: artist.image_url,
placeholder: '🎤',
name: artist.name,
meta: 'Artist',
badge: sourceBadge,
onClick: async () => {
const sourceOverride = _activeSearchSource;
console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`);
hideDropdown();
// Navigate to Artists page
navigateToPage('artists');
// Small delay to let the page load
await new Promise(resolve => setTimeout(resolve, 100));
// Load the artist details with source context
await selectArtistForDetail(artist, {
source: sourceOverride,
plugin: artist.external_urls?.hydrabase_plugin,
});
}
})
);
// Split albums from singles/EPs (albums is the catch-all for unknown types)
const allAlbums = data.spotify_albums || [];
const singlesAndEPs = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep');
const albums = allAlbums.filter(a => a.album_type !== 'single' && a.album_type !== 'ep');
// Render Albums
renderCompactSection(
'enh-albums-section',
'enh-albums-list',
'enh-albums-count',
albums,
(album) => ({
image: album.image_url,
placeholder: '💿',
name: album.name,
meta: `${album.artist} • ${album.release_date ? album.release_date.substring(0, 4) : 'N/A'}`,
onClick: () => handleEnhancedSearchAlbumClick(album)
})
);
// Render Singles & EPs
renderCompactSection(
'enh-singles-section',
'enh-singles-list',
'enh-singles-count',
singlesAndEPs,
(album) => ({
image: album.image_url,
placeholder: '🎶',
name: album.name,
meta: `${album.artist} • ${album.release_date ? album.release_date.substring(0, 4) : 'N/A'}`,
onClick: () => handleEnhancedSearchAlbumClick(album)
})
);
// Render Tracks
renderCompactSection(
'enh-tracks-section',
'enh-tracks-list',
'enh-tracks-count',
data.spotify_tracks || [],
(track) => {
const duration = formatDuration(track.duration_ms);
return {
image: track.image_url,
placeholder: '🎵',
name: track.name,
meta: `${track.artist} • ${track.album}`,
duration: duration,
onClick: () => handleEnhancedSearchTrackClick(track),
onPlay: () => streamEnhancedSearchTrack(track)
};
}
);
// Lazy load artist images that are missing
lazyLoadEnhancedSearchArtistImages();
// Async library ownership check — doesn't block rendering
_checkSearchResultsLibraryOwnership(data);
}
async function _checkSearchResultsLibraryOwnership(data) {
try {
const allAlbums = data.spotify_albums || [];
const allTracks = data.spotify_tracks || [];
if (!allAlbums.length && !allTracks.length) return;
const resp = 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 })),
tracks: allTracks.map(t => ({ name: t.name, artist: t.artist })),
}),
});
const result = await resp.json();
// Tag album cards with staggered animation
const albumCards = document.querySelectorAll('#enh-albums-list .enh-compact-item, #enh-singles-list .enh-compact-item');
const albumResults = result.albums || [];
let delay = 0;
albumCards.forEach((card, i) => {
if (albumResults[i]) {
setTimeout(() => {
const badge = document.createElement('div');
badge.className = 'enh-item-lib-badge';
badge.textContent = 'In Library';
card.appendChild(badge);
}, delay);
delay += 30;
}
});
// Tag track rows + wire up library playback
const trackCards = document.querySelectorAll('#enh-tracks-list .enh-compact-item');
const trackResults = result.tracks || [];
trackCards.forEach((card, i) => {
const tr = trackResults[i];
if (tr && tr.in_library) {
setTimeout(() => {
const badge = document.createElement('div');
badge.className = 'enh-item-lib-badge';
badge.textContent = 'In Library';
card.appendChild(badge);
// Replace stream button to play from library instead of searching
if (tr.file_path) {
const playBtn = card.querySelector('.enh-item-play-btn');
if (playBtn) {
const newBtn = playBtn.cloneNode(true);
newBtn.title = 'Play from library';
newBtn.textContent = '▶';
const trackInfo = tr;
newBtn.addEventListener('click', (e) => {
e.stopPropagation();
playLibraryTrack(
{ id: trackInfo.track_id, title: trackInfo.title, file_path: trackInfo.file_path },
trackInfo.album_title || '',
trackInfo.artist_name || ''
);
});
playBtn.replaceWith(newBtn);
}
}
}, delay);
delay += 30;
}
});
} catch (e) {
console.debug('Library check failed:', e);
}
}
async function _fetchAlternateSource(sourceName, query) {
try {
const response = await fetch(`/api/enhanced-search/source/${sourceName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
signal: _altSourceController?.signal,
});
if (!response.ok) return;
const result = await response.json();
if (!result.available) return;
// Store in multi-source state
if (_enhancedSearchData) {
_enhancedSearchData.sources[sourceName] = result;
// Re-render tabs if primary has loaded (primary_source is set)
if (_enhancedSearchData.primary_source) {
renderSourceTabs(_enhancedSearchData);
}
}
} catch (e) {
if (e.name !== 'AbortError') {
console.debug(`Alternate source ${sourceName} failed:`, e);
}
}
}
function renderSourceTabs(data) {
const tabBar = document.getElementById('enh-source-tabs');
if (!tabBar) return;
const sources = data.sources || {};
const primary = data.primary_source || 'spotify';
// Build tab list: primary first, then alternates sorted alphabetically
const sourceNames = Object.keys(sources).filter(s => sources[s].available);
if (sourceNames.length <= 1) {
tabBar.classList.add('hidden');
tabBar.innerHTML = '';
return;
}
// Primary tab first, then others
const ordered = [primary, ...sourceNames.filter(s => s !== primary).sort()];
tabBar.innerHTML = ordered.map(name => {
const info = SOURCE_LABELS[name] || { text: name, tabClass: '' };
const src = sources[name] || {};
const count = (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0);
const isActive = name === _activeSearchSource;
return ``;
}).join('');
tabBar.classList.remove('hidden');
}
// Expose tab switch globally (onclick from HTML)
window._switchEnhSourceTab = function(sourceName) {
if (!_enhancedSearchData || !_enhancedSearchData.sources) return;
const src = _enhancedSearchData.sources[sourceName];
if (!src) return;
_activeSearchSource = sourceName;
// Update tab active states
document.querySelectorAll('.enh-source-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.source === sourceName);
});
// Build data in the shape renderDropdownResults expects
const viewData = {
db_artists: _enhancedSearchData.db_artists,
spotify_artists: src.artists || [],
spotify_albums: src.albums || [],
spotify_tracks: src.tracks || [],
metadata_source: sourceName,
};
renderDropdownResults(viewData);
resultsContainer.classList.remove('hidden');
};
// Lazy load artist images for enhanced search results
async function lazyLoadEnhancedSearchArtistImages() {
const artistLists = [
document.getElementById('enh-db-artists-list'),
document.getElementById('enh-spotify-artists-list')
];
for (const list of artistLists) {
if (!list) continue;
const cardsNeedingImages = list.querySelectorAll('[data-needs-image="true"]');
if (cardsNeedingImages.length === 0) continue;
console.log(`🖼️ Lazy loading ${cardsNeedingImages.length} artist images in enhanced search`);
for (const card of cardsNeedingImages) {
const artistId = card.dataset.artistId;
if (!artistId) continue;
try {
const imgUrl = _activeSearchSource && _activeSearchSource !== 'spotify'
? `/api/artist/${artistId}/image?source=${_activeSearchSource}`
: `/api/artist/${artistId}/image`;
const response = await fetch(imgUrl);
const data = await response.json();
if (data.success && data.image_url) {
// Find the placeholder and replace with image
const placeholder = card.querySelector('.enh-item-image-placeholder');
if (placeholder) {
const img = document.createElement('img');
img.src = data.image_url;
img.className = 'enh-item-image artist-image';
img.alt = card.querySelector('.enh-item-name')?.textContent || 'Artist';
placeholder.replaceWith(img);
// Apply dynamic glow
extractImageColors(data.image_url, (colors) => {
applyDynamicGlow(card, colors);
});
}
card.dataset.needsImage = 'false';
console.log(`✅ Loaded image for artist ${artistId}`);
}
} catch (error) {
console.warn(`⚠️ Failed to load image for artist ${artistId}:`, error);
}
}
}
}
function formatDuration(durationMs) {
if (!durationMs) return '';
const totalSeconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function renderCompactSection(sectionId, listId, countId, items, mapItem) {
const section = document.getElementById(sectionId);
const list = document.getElementById(listId);
const count = document.getElementById(countId);
if (!list) return;
list.innerHTML = '';
if (!items || items.length === 0) {
section.classList.add('hidden');
return;
}
section.classList.remove('hidden');
count.textContent = items.length;
// Determine type based on section ID
const isArtist = sectionId.includes('artists');
const isAlbum = sectionId.includes('albums') || sectionId.includes('singles');
const isTrack = sectionId.includes('tracks');
// Add appropriate grid class to list
if (isArtist) {
list.classList.add('enh-artists-grid');
} else if (isAlbum) {
list.classList.add('enh-albums-grid');
} else if (isTrack) {
list.classList.add('enh-tracks-list');
}
items.forEach(item => {
const config = mapItem(item);
const elem = document.createElement('div');
// Add appropriate card class
if (isArtist) {
elem.className = 'enh-compact-item artist-card';
// Add data attributes for lazy loading
if (item.id) {
elem.dataset.artistId = item.id;
elem.dataset.needsImage = config.image ? 'false' : 'true';
}
} else if (isAlbum) {
elem.className = 'enh-compact-item album-card';
} else if (isTrack) {
elem.className = 'enh-compact-item track-item';
}
// Build image HTML with type-specific classes
let imageClass = 'enh-item-image';
let placeholderClass = 'enh-item-image-placeholder';
if (isArtist) {
imageClass += ' artist-image';
placeholderClass += ' artist-placeholder';
} else if (isAlbum) {
imageClass += ' album-cover';
placeholderClass += ' album-placeholder';
} else if (isTrack) {
imageClass += ' track-cover';
placeholderClass += ' track-placeholder';
}
const imageHtml = config.image
? ``
: `
`;
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 += `
';
}
}
// ===============================
// UTILITY FUNCTIONS
// ===============================
function showLoadingOverlay(message = 'Loading...') {
const overlay = document.getElementById('loading-overlay');
const messageElement = overlay.querySelector('.loading-message');
messageElement.textContent = message;
overlay.classList.remove('hidden');
}
function hideLoadingOverlay() {
document.getElementById('loading-overlay').classList.add('hidden');
}
// Toast deduplication cache
let recentToasts = new Map();
function showToast(message, type = 'success', helpSection = null) {
const container = document.getElementById('toast-container');
// Create a unique key for this toast
const toastKey = `${type}:${message}`;
const now = Date.now();
// Check if we've shown this exact toast recently (within 5 seconds)
if (recentToasts.has(toastKey)) {
const lastShown = recentToasts.get(toastKey);
if (now - lastShown < 5000) {
console.log(`🚫 Suppressing duplicate toast: "${message}"`);
return; // Don't show duplicate
}
}
// Record this toast
recentToasts.set(toastKey, now);
// Clean up old entries (older than 10 seconds)
for (const [key, timestamp] of recentToasts.entries()) {
if (now - timestamp > 10000) {
recentToasts.delete(key);
}
}
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
// Add contextual help link if a docs section is specified
if (helpSection) {
const helpLink = document.createElement('span');
helpLink.className = 'toast-help-link';
helpLink.textContent = 'Learn more';
helpLink.onclick = (e) => {
e.stopPropagation();
if (typeof navigateToDocsSection === 'function') {
navigateToDocsSection(helpSection);
}
};
toast.appendChild(helpLink);
}
container.appendChild(toast);
// Auto-remove after 3 seconds (5 seconds if has help link)
setTimeout(() => {
if (container.contains(toast)) {
container.removeChild(toast);
}
}, helpSection ? 5000 : 3000);
}
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;
}
// Determine discovery source from state
const identifier = currentDiscoveryFix.identifier;
const state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier];
const discoverySource = state?.discovery_source || state?.discoverySource || 'spotify';
const useFallback = discoverySource !== 'spotify';
const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results');
const sourceLabel = discoverySource === 'spotify' ? 'Spotify' : discoverySource === 'deezer' ? 'Deezer' : 'iTunes';
resultsContainer.innerHTML = `
🔍 Searching ${sourceLabel}...
`;
try {
// Build search params — send track and artist separately for field-specific filtering
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');
// Call appropriate search API based on discovery source
const searchEndpoint = useFallback ? '/api/itunes/search_tracks' : '/api/spotify/search_tracks';
const response = await fetch(`${searchEndpoint}?${params.toString()}`);
const data = await response.json();
if (data.error) {
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`);
}
// Make functions available globally for onclick handlers
window.openDiscoveryFixModal = openDiscoveryFixModal;
window.closeDiscoveryFixModal = closeDiscoveryFixModal;
window.searchDiscoveryFix = searchDiscoveryFix;
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 Transfer 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
Scans your transfer 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 downloads directory
Sweep Staging Folder — removes empty directories from the import staging area
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 a message to a Telegram chat via bot when the automation's action completes.
Configuration
Bot Token: Your Telegram bot token (from @BotFather)
Chat ID: The chat/group ID to send messages to
Message Template: Custom message with variable placeholders
Available variables
Use these in your message template:
{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');
modal.classList.remove('active');
document.body.style.overflow = ''; // Restore scrolling
}
// ===============================
// == 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');
}
}
async function loadDashboardData() {
// Attach event listeners for the DB updater tool
const updateButton = document.getElementById('db-update-button');
if (updateButton) {
updateButton.addEventListener('click', handleDbUpdateButtonClick);
}
// Attach event listeners for the metadata updater tool
const metadataButton = document.getElementById('metadata-update-button');
if (metadataButton) {
metadataButton.addEventListener('click', handleMetadataUpdateButtonClick);
}
// Check active media server and hide metadata updater if not Plex
await checkAndHideMetadataUpdaterForNonPlex();
// Check for ongoing metadata update and restore state
await checkAndRestoreMetadataUpdateState();
// Attach event listener for the quality scanner tool
const qualityScanButton = document.getElementById('quality-scan-button');
if (qualityScanButton) {
qualityScanButton.addEventListener('click', handleQualityScanButtonClick);
}
// Attach event listener for the duplicate cleaner tool
const duplicateCleanButton = document.getElementById('duplicate-clean-button');
if (duplicateCleanButton) {
duplicateCleanButton.addEventListener('click', handleDuplicateCleanButtonClick);
}
// Attach event listener for the retag tool
const retagOpenButton = document.getElementById('retag-open-button');
if (retagOpenButton) {
retagOpenButton.addEventListener('click', openRetagModal);
}
// Attach event listener for the media scan tool
const mediaScanButton = document.getElementById('media-scan-button');
if (mediaScanButton) {
mediaScanButton.addEventListener('click', handleMediaScanButtonClick);
}
// Check active media server and show media scan tool only for Plex
await checkAndShowMediaScanForPlex();
// Attach event listener for the backup manager
const backupNowButton = document.getElementById('backup-now-button');
if (backupNowButton) backupNowButton.addEventListener('click', handleBackupNowClick);
loadBackupList();
// Attach event listeners for tool help buttons
initializeToolHelpButtons();
// Attach event listener for the wishlist button
const wishlistButton = document.getElementById('wishlist-button');
if (wishlistButton) {
wishlistButton.addEventListener('click', handleWishlistButtonClick);
}
// Initial load of retag stats
loadRetagStats();
// Check for ongoing retag operation
checkRetagStatus();
// Initial load of stats
await fetchAndUpdateDbStats();
// Start periodic refresh of stats (every 10 seconds)
stopDbStatsPolling(); // Ensure no duplicates
dbStatsInterval = setInterval(fetchAndUpdateDbStats, 10000);
// Initial load of discovery pool stats for the tool card
loadDiscoveryPoolStats();
// Initial load of metadata cache stats
loadMetadataCacheStats();
// 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 and system statistics
await fetchAndUpdateServiceStatus();
await fetchAndUpdateSystemStats();
// 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);
// Also check the status of any ongoing update when the page loads
await checkAndUpdateDbProgress();
// Check for any ongoing quality scanner when the page loads
await checkAndUpdateQualityScanProgress();
// Check for any ongoing duplicate cleaner when the page loads
await checkAndUpdateDuplicateCleanProgress();
// 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) {
// You can expand this later to update the main stat cards
// For now, we focus on the updater tool itself.
}
/**
* 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
if (hasSpotifyMatches) {
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 += ``;
}
if (!buttons) {
buttons = `
ℹ️ No Spotify matches found. Discovery complete but no tracks could be matched.
`;
}
return buttons;
case 'syncing':
if (isListenBrainz) {
return `
♪ 0/✓ 0/✗ 0(0%)
`;
} else if (isTidal) {
return `
♪ 0/✓ 0/✗ 0(0%)
`;
} else if (isDeezer) {
return `
♪ 0/✓ 0/✗ 0(0%)
`;
} else if (isSpotifyPublic) {
return `
♪ 0/✓ 0/✗ 0(0%)
`;
} else if (isBeatport) {
return `
♪ 0/✓ 0/✗ 0(0%)
`;
} else {
return `
♪ 0/✓ 0/✗ 0(0%)
`;
}
case 'sync_complete':
let syncCompleteButtons = '';
// Only show sync button if there are Spotify matches
if (hasSpotifyMatches) {
if (isListenBrainz) {
syncCompleteButtons += ``;
} else if (isTidal) {
syncCompleteButtons += ``;
} else if (isSpotifyPublic) {
syncCompleteButtons += ``;
} else if (isBeatport) {
syncCompleteButtons += ``;
} else {
syncCompleteButtons += ``;
}
}
// Only show download button if we have matches or a converted playlist ID
if (hasSpotifyMatches || hasConvertedPlaylistId) {
if (isListenBrainz) {
syncCompleteButtons += ``;
} else if (isTidal) {
syncCompleteButtons += ``;
} else if (isSpotifyPublic) {
syncCompleteButtons += ``;
} else if (isBeatport) {
syncCompleteButtons += ``;
} else {
syncCompleteButtons += ``;
}
}
// Rediscover button (only for sources with reset endpoints)
if (isBeatport) {
syncCompleteButtons += ``;
} else if (!isListenBrainz && !isTidal && !isDeezer && !isSpotifyPublic) {
syncCompleteButtons += ``;
}
return syncCompleteButtons;
case 'download_complete':
// Same options as sync_complete — allow re-sync, download missing, and reset
let dlCompleteButtons = '';
if (hasSpotifyMatches) {
if (isListenBrainz) {
dlCompleteButtons += ``;
} else if (isTidal) {
dlCompleteButtons += ``;
} else if (isDeezer) {
dlCompleteButtons += ``;
} else if (isSpotifyPublic) {
dlCompleteButtons += ``;
} else if (isBeatport) {
dlCompleteButtons += ``;
} else {
dlCompleteButtons += ``;
}
}
if (hasSpotifyMatches || hasConvertedPlaylistId) {
if (isListenBrainz) {
dlCompleteButtons += ``;
} else if (isTidal) {
dlCompleteButtons += ``;
} else if (isDeezer) {
dlCompleteButtons += ``;
} else if (isSpotifyPublic) {
dlCompleteButtons += ``;
} else if (isBeatport) {
dlCompleteButtons += ``;
} else {
dlCompleteButtons += ``;
}
}
// Rediscover button (only for sources with reset endpoints)
if (isBeatport) {
dlCompleteButtons += ``;
} else if (!isListenBrainz && !isTidal && !isDeezer && !isSpotifyPublic) {
dlCompleteButtons += ``;
}
return dlCompleteButtons;
default:
return '';
}
}
function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false, isSpotifyPublic = false) {
const source = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'Spotify' : (isDeezer ? 'Deezer' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')))));
switch (phase) {
case 'fresh':
return `Ready to discover clean ${currentMusicSourceName} metadata for ${source} tracks...`;
case 'discovering':
return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`;
case 'discovered':
case 'downloading':
case 'download_complete':
return 'Discovery complete! View the results below.';
default:
return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`;
}
}
function getInitialProgressText(phase, isTidal = false, isBeatport = false, isListenBrainz = false) {
switch (phase) {
case 'fresh':
return 'Click Start Discovery to begin...';
case 'discovering':
return 'Starting discovery...';
case 'discovered':
case 'downloading':
case 'download_complete':
return 'Discovery completed!';
default:
return 'Starting discovery...';
}
}
function generateTableRowsFromState(state, urlHash) {
const isTidal = state.is_tidal_playlist;
const isDeezer = state.is_deezer_playlist;
const isSpotifyPublic = state.is_spotify_public_playlist;
const isBeatport = state.is_beatport_playlist;
const isListenBrainz = state.is_listenbrainz_playlist;
const isMirrored = state.is_mirrored_playlist;
const platform = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'spotify_public' : (isDeezer ? 'deezer' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isBeatport ? 'beatport' : 'youtube')))));
// Support both camelCase and snake_case
const discoveryResults = state.discoveryResults || state.discovery_results;
if (discoveryResults && discoveryResults.length > 0) {
// Generate rows from existing discovery results
return discoveryResults.map((result, index) => {
// Handle different field names based on platform
const trackName = result.lb_track || result.yt_track || result.track_name || '-';
const artistName = result.lb_artist || result.yt_artist || result.artist_name || '-';
return `
`;
}
}
// Auto-switch to Singles tab if no albums but has singles
if ((!discography.albums || discography.albums.length === 0) &&
discography.singles && discography.singles.length > 0) {
console.log('📀 No albums found, auto-switching to Singles & EPs tab');
// Switch to singles tab
const albumsTab = document.getElementById('albums-tab');
const singlesTab = document.getElementById('singles-tab');
const albumsContent = document.getElementById('albums-content');
const singlesContent = document.getElementById('singles-content');
if (albumsTab && singlesTab && albumsContent && singlesContent) {
// Remove active from albums
albumsTab.classList.remove('active');
albumsContent.classList.remove('active');
// Add active to singles
singlesTab.classList.add('active');
singlesContent.classList.add('active');
}
}
}
/**
* Load similar artists from MusicMap
*/
async function loadSimilarArtists(artistName) {
if (!artistName) {
console.warn('⚠️ No artist name provided for similar artists');
return;
}
console.log(`🔍 Loading similar artists for: ${artistName}`);
// Get DOM elements
const section = document.getElementById('similar-artists-section');
const loadingEl = document.getElementById('similar-artists-loading');
const errorEl = document.getElementById('similar-artists-error');
const container = document.getElementById('similar-artists-bubbles-container');
if (!section || !loadingEl || !errorEl || !container) {
console.warn('⚠️ Similar artists section elements not found');
return;
}
// Show loading state
loadingEl.classList.remove('hidden');
errorEl.classList.add('hidden');
container.innerHTML = '';
section.style.display = 'block';
try {
// Create new abort controller for this similar artists stream
similarArtistsController = new AbortController();
// Use streaming endpoint for real-time bubble creation
const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`;
console.log(`📡 Streaming from: ${url}`);
const response = await fetch(url, {
signal: similarArtistsController.signal
});
if (!response.ok) {
throw new Error(`Failed to fetch similar artists: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let artistCount = 0;
// Read the stream
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('✅ Stream complete');
break;
}
// Decode the chunk and add to buffer
buffer += decoder.decode(value, { stream: true });
// Process complete messages (separated by \n\n)
const messages = buffer.split('\n\n');
buffer = messages.pop() || ''; // Keep incomplete message in buffer
for (const message of messages) {
if (!message.trim() || !message.startsWith('data: ')) continue;
try {
const jsonData = JSON.parse(message.substring(6)); // Remove 'data: ' prefix
if (jsonData.error) {
throw new Error(jsonData.error);
}
if (jsonData.artist) {
// Hide loading on first artist
if (artistCount === 0) {
loadingEl.classList.add('hidden');
}
// Create and append bubble immediately
const bubble = createSimilarArtistBubble(jsonData.artist);
container.appendChild(bubble);
artistCount++;
console.log(`✅ Added bubble for: ${jsonData.artist.name} (${artistCount})`);
}
if (jsonData.complete) {
console.log(`🎉 Streaming complete: ${jsonData.total} artists`);
if (artistCount === 0) {
loadingEl.classList.add('hidden');
container.innerHTML = `
🎵
No similar artists found
`;
} else {
// Lazy load images for similar artists that don't have them
lazyLoadSimilarArtistImages(container);
}
}
} catch (parseError) {
console.error('❌ Error parsing stream message:', parseError);
}
}
}
// Clear the controller when done
similarArtistsController = null;
} catch (error) {
// Don't show error if it was aborted (user navigated away)
if (error.name === 'AbortError') {
console.log('⏹️ Similar artists stream aborted (user navigated to new artist)');
loadingEl.classList.add('hidden');
return;
}
console.error('❌ Error loading similar artists:', error);
// Hide loading, show error
loadingEl.classList.add('hidden');
errorEl.classList.remove('hidden');
// Also show error message in container
container.innerHTML = `
⚠️
${error.message}
`;
} finally {
// Always clear the controller
similarArtistsController = null;
}
}
/**
* Lazy load images for similar artist bubbles that don't have images
*/
async function lazyLoadSimilarArtistImages(container) {
if (!container) return;
const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]');
if (bubblesNeedingImages.length === 0) {
console.log('✅ All similar artist bubbles have images');
return;
}
console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`);
// Load images in parallel batches
const batchSize = 5;
const bubbles = Array.from(bubblesNeedingImages);
for (let i = 0; i < bubbles.length; i += batchSize) {
const batch = bubbles.slice(i, i + batchSize);
await Promise.all(batch.map(async (bubble) => {
const artistId = bubble.getAttribute('data-artist-id');
if (!artistId) return;
try {
const response = await fetch(`/api/artist/${artistId}/image`);
const data = await response.json();
if (data.success && data.image_url) {
const imageContainer = bubble.querySelector('.similar-artist-bubble-image');
if (imageContainer) {
const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist';
imageContainer.innerHTML = ``;
bubble.setAttribute('data-needs-image', 'false');
console.log(`✅ Loaded image for similar artist ${artistId}`);
}
}
} catch (error) {
console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error);
}
}));
}
console.log('✅ Finished lazy loading similar artist images');
}
/**
* Display similar artist bubble cards progressively (one at a time with delay)
*/
function displaySimilarArtistsProgressively(artists) {
const container = document.getElementById('similar-artists-bubbles-container');
if (!container) {
console.warn('⚠️ Similar artists container not found');
return;
}
// Clear container
container.innerHTML = '';
// Add each bubble with a delay to simulate progressive loading
artists.forEach((artist, index) => {
setTimeout(() => {
const bubble = createSimilarArtistBubble(artist);
container.appendChild(bubble);
}, index * 100); // 100ms delay between each bubble
});
console.log(`✅ Displaying ${artists.length} similar artist bubbles progressively`);
}
/**
* Display similar artist bubble cards (all at once - legacy)
*/
function displaySimilarArtists(artists) {
const container = document.getElementById('similar-artists-bubbles-container');
if (!container) {
console.warn('⚠️ Similar artists container not found');
return;
}
// Clear container
container.innerHTML = '';
// Create bubble cards with staggered animation
artists.forEach((artist, index) => {
const bubble = createSimilarArtistBubble(artist);
// Add staggered animation delay (50ms per bubble)
bubble.style.animationDelay = `${index * 0.05}s`;
container.appendChild(bubble);
});
console.log(`✅ Displayed ${artists.length} similar artist bubbles`);
}
/**
* Create a similar artist bubble card element
*/
function createSimilarArtistBubble(artist) {
// Create bubble container
const bubble = document.createElement('div');
bubble.className = 'similar-artist-bubble';
bubble.setAttribute('data-artist-id', artist.id);
// Track if image needs lazy loading
const hasImage = artist.image_url && artist.image_url.trim() !== '';
bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true');
// Create image container
const imageContainer = document.createElement('div');
imageContainer.className = 'similar-artist-bubble-image';
if (hasImage) {
const img = document.createElement('img');
img.src = artist.image_url;
img.alt = artist.name;
// Handle image load error
img.onerror = () => {
console.log(`Failed to load image for ${artist.name}`);
imageContainer.innerHTML = `
🎵
`;
bubble.setAttribute('data-needs-image', 'true');
};
imageContainer.appendChild(img);
} else {
// No image - show fallback (will be lazy loaded)
imageContainer.innerHTML = `
🎵
`;
}
// Create name element
const name = document.createElement('div');
name.className = 'similar-artist-bubble-name';
name.textContent = artist.name;
name.title = artist.name; // Tooltip for full name
// Optional: Create genres element (hidden by default in CSS)
const genres = document.createElement('div');
genres.className = 'similar-artist-bubble-genres';
if (artist.genres && artist.genres.length > 0) {
genres.textContent = artist.genres.slice(0, 2).join(', ');
}
// Assemble bubble
bubble.appendChild(imageContainer);
bubble.appendChild(name);
if (artist.genres && artist.genres.length > 0) {
bubble.appendChild(genres);
}
// Add click handler to navigate to artist detail page
bubble.addEventListener('click', () => {
console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`);
// Navigate to this artist's detail page (same as clicking from search results)
selectArtistForDetail(artist);
});
return bubble;
}
/**
* Restore cached completion data without re-scanning the database
*/
function restoreCachedCompletionData(artistId) {
console.log(`📦 Restoring cached completion data for artist: ${artistId}`);
const cachedData = artistsPageState.cache.completionData[artistId];
if (!cachedData) {
console.log('⚠️ No cached completion data found, skipping restoration');
return;
}
// Restore album completion overlays
if (cachedData.albums) {
cachedData.albums.forEach(albumCompletion => {
updateAlbumCompletionOverlay(albumCompletion, 'albums');
});
console.log(`✅ Restored ${cachedData.albums.length} album completion overlays`);
}
// Restore singles completion overlays
if (cachedData.singles) {
cachedData.singles.forEach(singleCompletion => {
updateAlbumCompletionOverlay(singleCompletion, 'singles');
});
console.log(`✅ Restored ${cachedData.singles.length} single completion overlays`);
}
}
/**
* Check completion status for entire discography with streaming updates
*/
async function checkDiscographyCompletion(artistId, discography) {
console.log(`🔍 Starting streaming completion check for artist: ${artistId}`);
try {
// Create new abort controller for this completion check
artistCompletionController = new AbortController();
// Use fetch with streaming response
const response = await fetch(`/api/artist/${artistId}/completion-stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
discography: discography,
artist_name: artistsPageState.selectedArtist?.name || 'Unknown Artist',
test_mode: window.location.search.includes('test=true')
}),
signal: artistCompletionController.signal
});
if (!response.ok) {
throw new Error(`Failed to start completion check: ${response.status}`);
}
// Handle streaming response
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
handleStreamingCompletionUpdate(data);
} catch (e) {
console.warn('Failed to parse streaming data:', line);
}
}
}
}
// Clear the controller when done
artistCompletionController = null;
} catch (error) {
// Don't show error if it was aborted (user navigated away)
if (error.name === 'AbortError') {
console.log('⏹️ Completion check aborted (user navigated to new artist)');
return;
}
console.error('❌ Failed to check completion status:', error);
showCompletionError();
} finally {
// Always clear the controller
artistCompletionController = null;
}
}
/**
* Handle individual streaming completion updates
*/
function handleStreamingCompletionUpdate(data) {
console.log('🔄 Streaming update received:', data.type, data.name || data.artist_name);
switch (data.type) {
case 'start':
console.log(`🎤 Starting completion check for ${data.artist_name} (${data.total_items} items)`);
// Initialize cache for this artist if not exists
const artistId = artistsPageState.selectedArtist?.id;
if (artistId && !artistsPageState.cache.completionData[artistId]) {
artistsPageState.cache.completionData[artistId] = {
albums: [],
singles: []
};
}
break;
case 'album_completion':
updateAlbumCompletionOverlay(data, 'albums');
// Cache the completion data
cacheCompletionData(data, 'albums');
console.log(`📀 Updated album: ${data.name} (${data.status})`);
break;
case 'single_completion':
updateAlbumCompletionOverlay(data, 'singles');
// Cache the completion data
cacheCompletionData(data, 'singles');
console.log(`🎵 Updated single: ${data.name} (${data.status})`);
break;
case 'error':
console.error('❌ Error processing item:', data.name, data.error);
// Could show error for specific item
break;
case 'complete':
console.log(`✅ Completion check finished (${data.processed_count} items processed)`);
break;
default:
console.log('Unknown streaming update type:', data.type);
}
}
/**
* Cache completion data for future restoration
*/
function cacheCompletionData(completionData, type) {
const artistId = artistsPageState.selectedArtist?.id;
if (!artistId) return;
// Ensure cache structure exists
if (!artistsPageState.cache.completionData[artistId]) {
artistsPageState.cache.completionData[artistId] = {
albums: [],
singles: []
};
}
// Add to appropriate cache array
if (type === 'albums') {
artistsPageState.cache.completionData[artistId].albums.push(completionData);
} else if (type === 'singles') {
artistsPageState.cache.completionData[artistId].singles.push(completionData);
}
}
/**
* Update completion overlay for a specific album/single
*/
function updateAlbumCompletionOverlay(completionData, containerType) {
const containerId = containerType === 'albums' ? 'album-cards-container' : 'singles-cards-container';
const container = document.getElementById(containerId);
if (!container) {
console.warn(`Container ${containerId} not found`);
return;
}
// Find the album card by data-album-id
const albumCard = container.querySelector(`[data-album-id="${completionData.id}"]`);
if (!albumCard) {
console.warn(`Album card not found for ID: ${completionData.id}`);
return;
}
const overlay = albumCard.querySelector('.completion-overlay');
if (!overlay) {
console.warn(`Completion overlay not found for album: ${completionData.name}`);
return;
}
// Remove existing status classes
overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error');
// Add new status class
overlay.classList.add(completionData.status);
// Update overlay text and content
const statusText = getCompletionStatusText(completionData);
const progressText = `${completionData.owned_tracks}/${completionData.expected_tracks}`;
overlay.innerHTML = `
${statusText}${progressText}
`;
// Add tooltip with more details
overlay.title = `${completionData.name}\n${statusText} (${completionData.completion_percentage}%)\nTracks: ${completionData.owned_tracks}/${completionData.expected_tracks}\nConfidence: ${completionData.confidence}`;
// Add brief flash animation to indicate update
overlay.style.animation = 'none';
overlay.offsetHeight; // Trigger reflow
overlay.style.animation = 'completionOverlayFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1)';
console.log(`📊 Updated overlay for "${completionData.name}": ${statusText} (${completionData.completion_percentage}%)`);
}
/**
* Get human-readable status text for completion overlay
*/
function getCompletionStatusText(completionData) {
switch (completionData.status) {
case 'completed':
return 'Complete';
case 'nearly_complete':
return 'Nearly Complete';
case 'partial':
return 'Partial';
case 'missing':
return 'Missing';
case 'downloading':
return 'Downloading...';
case 'downloaded':
return 'Downloaded';
case 'error':
return 'Error';
default:
return 'Unknown';
}
}
/**
* Set album to downloaded status after download finishes
*/
function setAlbumDownloadedStatus(albumId) {
console.log(`✅ [DOWNLOAD COMPLETE] Setting album ${albumId} to downloaded status`);
const completionData = {
id: albumId,
status: 'downloaded',
owned_tracks: 0,
expected_tracks: 0,
name: 'Downloaded',
completion_percentage: 100
};
// Find if it's in albums or singles container
let containerType = 'albums';
let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`);
if (!albumCard) {
containerType = 'singles';
albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`);
}
if (albumCard) {
updateAlbumCompletionOverlay(completionData, containerType);
console.log(`✅ [DOWNLOAD COMPLETE] Album ${albumId} set to Downloaded status`);
} else {
console.warn(`❌ [DOWNLOAD COMPLETE] Album card not found for ID: "${albumId}"`);
}
}
/**
* Set album to downloading status
*/
function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) {
console.log(`🔍 [DOWNLOAD STATUS] Searching for album card with ID: "${albumId}"`);
const completionData = {
id: albumId,
status: 'downloading',
owned_tracks: downloaded,
expected_tracks: total,
name: 'Downloading',
completion_percentage: Math.round((downloaded / total) * 100) || 0
};
// Find if it's in albums or singles container
let containerType = 'albums';
let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`);
if (!albumCard) {
containerType = 'singles';
albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`);
}
if (albumCard) {
console.log(`✅ [DOWNLOAD STATUS] Found album card in ${containerType} container, updating overlay`);
updateAlbumCompletionOverlay(completionData, containerType);
} else {
console.warn(`❌ [DOWNLOAD STATUS] Album card not found for ID: "${albumId}"`);
// Debug: List all available album cards
const allAlbums = document.querySelectorAll('#album-cards-container [data-album-id], #singles-cards-container [data-album-id]');
console.log(`🔍 [DEBUG] Available album IDs:`, Array.from(allAlbums).map(card => card.dataset.albumId));
}
}
/**
* Show error state on all completion overlays
*/
function showCompletionError() {
const allOverlays = document.querySelectorAll('.completion-overlay.checking');
allOverlays.forEach(overlay => {
overlay.classList.remove('checking');
overlay.classList.add('error');
overlay.innerHTML = 'Error';
overlay.title = 'Failed to check completion status';
});
}
/**
* Create HTML for an album/single card
*/
function createAlbumCardHTML(album) {
const imageUrl = album.image_url || '';
const year = album.release_date ? new Date(album.release_date).getFullYear() : '';
const type = album.album_type === 'album' ? 'Album' :
album.album_type === 'single' ? 'Single' : 'EP';
// Use data-bg-src for lazy background loading via IntersectionObserver
const backgroundAttr = imageUrl ?
`data-bg-src="${imageUrl}"` :
`style="background: linear-gradient(135deg, rgba(29, 185, 84, 0.2) 0%, rgba(24, 156, 71, 0.1) 100%);"`;
return `
Checking...
${escapeHtml(album.name)}
${year || 'Unknown'}
${type}
`;
}
/**
* Initialize artist detail tabs
*/
function initializeArtistTabs() {
const tabButtons = document.querySelectorAll('.artist-tab');
const tabContents = document.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabName = button.getAttribute('data-tab');
// Update button states
tabButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Update content states
tabContents.forEach(content => {
content.classList.remove('active');
if (content.id === `${tabName}-content`) {
content.classList.add('active');
}
});
console.log(`🔄 Switched to ${tabName} tab`);
});
});
}
/**
* State management functions
*/
function showArtistsSearchState() {
console.log('🔄 Showing search state');
// Cancel any ongoing completion check when navigating back to search
if (artistCompletionController) {
console.log('⏹️ Canceling completion check (navigating back to search)');
artistCompletionController.abort();
artistCompletionController = null;
}
// Cancel any ongoing similar artists stream when navigating back to search
if (similarArtistsController) {
console.log('⏹️ Canceling similar artists stream (navigating back to search)');
similarArtistsController.abort();
similarArtistsController = null;
}
const searchState = document.getElementById('artists-search-state');
const resultsState = document.getElementById('artists-results-state');
const detailState = document.getElementById('artist-detail-state');
if (searchState) {
searchState.classList.remove('hidden', 'fade-out');
}
if (resultsState) {
resultsState.classList.add('hidden');
resultsState.classList.remove('show');
}
if (detailState) {
detailState.classList.add('hidden');
detailState.classList.remove('show');
}
artistsPageState.currentView = 'search';
updateArtistsSearchStatus('default');
// Show artist downloads section if there are active downloads
showArtistDownloadsSection();
}
function showArtistsResultsState() {
console.log('🔄 Showing results state');
// Cancel any ongoing completion check when navigating back
if (artistCompletionController) {
console.log('⏹️ Canceling completion check (navigating back to results)');
artistCompletionController.abort();
artistCompletionController = null;
}
// Cancel any ongoing similar artists stream when navigating back
if (similarArtistsController) {
console.log('⏹️ Canceling similar artists stream (navigating back to results)');
similarArtistsController.abort();
similarArtistsController = null;
}
// Clear artist-specific data when navigating back to results
// This ensures that selecting the same artist again will trigger a fresh scan
if (artistsPageState.selectedArtist) {
const artistId = artistsPageState.selectedArtist.id;
console.log(`🗑️ Clearing cached data for artist: ${artistsPageState.selectedArtist.name}`);
// Clear artist-specific cache data
delete artistsPageState.cache.completionData[artistId];
delete artistsPageState.cache.discography[artistId];
// Clear artist state
artistsPageState.selectedArtist = null;
artistsPageState.artistDiscography = { albums: [], singles: [] };
}
const searchState = document.getElementById('artists-search-state');
const resultsState = document.getElementById('artists-results-state');
const detailState = document.getElementById('artist-detail-state');
if (searchState) {
searchState.classList.add('fade-out');
setTimeout(() => searchState.classList.add('hidden'), 200);
}
if (resultsState) {
resultsState.classList.remove('hidden');
setTimeout(() => resultsState.classList.add('show'), 50);
}
if (detailState) {
detailState.classList.add('hidden');
detailState.classList.remove('show');
}
artistsPageState.currentView = 'results';
}
function showArtistDetailState() {
console.log('🔄 Showing detail state');
const searchState = document.getElementById('artists-search-state');
const resultsState = document.getElementById('artists-results-state');
const detailState = document.getElementById('artist-detail-state');
if (searchState) {
searchState.classList.add('hidden', 'fade-out');
}
if (resultsState) {
resultsState.classList.add('hidden');
resultsState.classList.remove('show');
}
if (detailState) {
detailState.classList.remove('hidden');
setTimeout(() => detailState.classList.add('show'), 50);
}
artistsPageState.currentView = 'detail';
}
/**
* Update search status text and styling
*/
function updateArtistsSearchStatus(status, message = null) {
const statusElement = document.getElementById('artists-search-status');
if (!statusElement) return;
// Clear all status classes
statusElement.classList.remove('searching', 'error');
switch (status) {
case 'default':
statusElement.textContent = 'Start typing to search for artists';
break;
case 'searching':
statusElement.classList.add('searching');
statusElement.textContent = 'Searching for artists...';
break;
case 'error':
statusElement.classList.add('error');
statusElement.innerHTML = `
${message || 'Search failed. Please try again.'}
`;
break;
}
}
/**
* Retry the last search query
*/
function retryLastSearch() {
const searchInput = document.getElementById('artists-search-input');
const headerSearchInput = document.getElementById('artists-header-search-input');
// Get the last search query from either input
const query = searchInput?.value?.trim() || headerSearchInput?.value?.trim() || artistsPageState.searchQuery;
if (query) {
console.log(`🔄 Retrying search for: "${query}"`);
performArtistsSearch(query);
}
}
/**
* Update artist detail header with artist info
*/
function updateArtistDetailHeader(artist) {
const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
const info = artist.artist_info || {};
const imageUrl = artist.image_url || info.image_url || '';
// Background blur
const heroBg = document.getElementById('artists-hero-bg');
if (heroBg) {
heroBg.style.backgroundImage = imageUrl ? `url('${imageUrl}')` : 'none';
}
// Artist image
const heroImage = document.getElementById('artists-hero-image');
if (heroImage) {
if (imageUrl) {
heroImage.style.backgroundImage = `url('${imageUrl}')`;
heroImage.innerHTML = '';
} else {
heroImage.style.backgroundImage = 'none';
heroImage.innerHTML = '🎤';
// Lazy load
fetch(`/api/artist/${artist.id}/image`)
.then(r => r.json())
.then(d => {
if (d.success && d.image_url) {
heroImage.style.backgroundImage = `url('${d.image_url}')`;
heroImage.innerHTML = '';
if (heroBg) heroBg.style.backgroundImage = `url('${d.image_url}')`;
artist.image_url = d.image_url;
}
}).catch(() => {});
}
}
// Name
const heroName = document.getElementById('artists-hero-name');
if (heroName) heroName.textContent = artist.name || 'Unknown Artist';
// Badges (service links — real logos matching library page)
const badgesEl = document.getElementById('artists-hero-badges');
if (badgesEl) {
const _hb = (logo, fallback, title, url) => {
const attr = url ? `data-url="${_esc(url)}" onclick="window.open(this.dataset.url,'_blank')"` : '';
const inner = logo
? ``
: `${fallback}`;
return `
`;
}
}
function _renderWatchAllModalContent(overlay, eligible, ineligible, sourceName) {
const body = overlay.querySelector('.watch-all-body');
const confirmBtn = overlay.querySelector('#watch-all-confirm-btn');
if (eligible.length === 0 && ineligible.length === 0) {
body.innerHTML = '
🎵
No unwatched artists found
';
return;
}
// Store data for search filtering
overlay._watchAllEligible = eligible;
overlay._watchAllIneligible = ineligible;
let html = '';
// Summary bar (sticky)
html += '
';
html += `
${eligible.length}
Ready to watch
`;
html += `
${ineligible.length}
No ${_esc(sourceName)} ID
`;
html += `
${eligible.length + ineligible.length}
Total unwatched
`;
html += '
';
// Search filter
if (eligible.length > 10) {
html += '';
}
// Eligible grid
if (eligible.length > 0) {
html += '
Artists to be watched
';
html += '
';
html += _buildWatchAllRows(eligible, false);
html += '
';
}
// Ineligible section
if (ineligible.length > 0) {
html += `
⚠${ineligible.length} artist${ineligible.length !== 1 ? 's' : ''} without ${_esc(sourceName)} ID
▼
These artists haven't been matched to ${_esc(sourceName)} yet. The background enrichment worker will match them over time.
${_buildWatchAllRows(ineligible, true)}
`;
}
if (eligible.length === 0) {
html += `
🔌
None of your unwatched artists have a ${_esc(sourceName)} ID yet
The background enrichment worker will match them over time.
`;
}
body.innerHTML = html;
if (eligible.length > 0 && confirmBtn) {
confirmBtn.textContent = `Watch All (${eligible.length})`;
confirmBtn.disabled = false;
confirmBtn.onclick = () => _confirmWatchAllUnwatched(overlay, eligible.length);
}
}
function _buildWatchAllRows(artists, dimmed) {
let html = '';
for (const a of artists) {
const img = a.image_url
? `
`;
sliderTrack.insertAdjacentHTML('beforeend', slideHtml);
console.log(`🔥 Created slide ${slideIndex + 1}/${slides.length} with ${slideReleases.length} releases`);
// Create indicator
const indicatorHtml = ``;
indicatorsContainer.insertAdjacentHTML('beforeend', indicatorHtml);
});
// Add click handlers to track cards
setupBeatportHypePickCardHandlers();
}
/**
* Create a hype pick card HTML (for release cards, same as new releases)
*/
function createBeatportHypePickCard(release) {
const artworkUrl = release.image_url || '';
const bgStyle = artworkUrl ? `style="--card-bg-image: url('${artworkUrl}')"` : '';
return `
${artworkUrl ? `` : ''}
${release.title || 'Unknown Title'}
${release.artist || 'Unknown Artist'}
${release.label || 'Hype Pick'}
`;
}
/**
* Setup navigation for hype picks slider (same pattern as releases)
*/
function setupBeatportHypePicksSliderNavigation() {
const prevBtn = document.getElementById('beatport-hype-picks-prev-btn');
const nextBtn = document.getElementById('beatport-hype-picks-next-btn');
if (prevBtn) {
// Clone button to remove all existing event listeners
const newPrevBtn = prevBtn.cloneNode(true);
prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn);
newPrevBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('Previous hype picks button clicked, current slide:', beatportHypePicksSliderState.currentSlide);
goToBeatportHypePicksSlide(beatportHypePicksSliderState.currentSlide - 1);
resetBeatportHypePicksSliderAutoPlay();
});
}
if (nextBtn) {
// Clone button to remove all existing event listeners
const newNextBtn = nextBtn.cloneNode(true);
nextBtn.parentNode.replaceChild(newNextBtn, nextBtn);
newNextBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('Next hype picks button clicked, current slide:', beatportHypePicksSliderState.currentSlide);
goToBeatportHypePicksSlide(beatportHypePicksSliderState.currentSlide + 1);
resetBeatportHypePicksSliderAutoPlay();
});
}
}
/**
* Setup indicators for hype picks slider
*/
function setupBeatportHypePicksSliderIndicators() {
const indicators = document.querySelectorAll('.beatport-hype-picks-indicator');
indicators.forEach((indicator, index) => {
indicator.addEventListener('click', () => {
goToBeatportHypePicksSlide(index);
resetBeatportHypePicksSliderAutoPlay();
});
});
}
/**
* Navigate to specific slide
*/
function goToBeatportHypePicksSlide(slideIndex) {
console.log('goToBeatportHypePicksSlide called with:', slideIndex, 'current:', beatportHypePicksSliderState.currentSlide);
// Handle wrap around
if (slideIndex < 0) {
slideIndex = beatportHypePicksSliderState.totalSlides - 1;
} else if (slideIndex >= beatportHypePicksSliderState.totalSlides) {
slideIndex = 0;
}
// Update current slide
beatportHypePicksSliderState.currentSlide = slideIndex;
// Update slides
const slides = document.querySelectorAll('.beatport-hype-picks-slide');
slides.forEach((slide, index) => {
slide.classList.remove('active', 'prev', 'next');
if (index === slideIndex) {
slide.classList.add('active');
} else if (index < slideIndex) {
slide.classList.add('prev');
} else {
slide.classList.add('next');
}
});
// Update indicators
const indicators = document.querySelectorAll('.beatport-hype-picks-indicator');
indicators.forEach((indicator, index) => {
indicator.classList.toggle('active', index === slideIndex);
});
console.log('Slide updated to:', beatportHypePicksSliderState.currentSlide);
}
/**
* Start auto-play for hype picks slider
*/
function startBeatportHypePicksSliderAutoPlay() {
if (beatportHypePicksSliderState.autoPlayInterval) {
clearInterval(beatportHypePicksSliderState.autoPlayInterval);
}
beatportHypePicksSliderState.autoPlayInterval = setInterval(() => {
goToBeatportHypePicksSlide(beatportHypePicksSliderState.currentSlide + 1);
}, beatportHypePicksSliderState.autoPlayDelay);
console.log('🔥 Hype picks slider autoplay started');
}
/**
* Reset auto-play for hype picks slider
*/
function resetBeatportHypePicksSliderAutoPlay() {
startBeatportHypePicksSliderAutoPlay();
}
/**
* Setup hover pause for hype picks slider
*/
function setupBeatportHypePicksSliderHoverPause() {
const sliderContainer = document.querySelector('.beatport-hype-picks-slider-container');
if (sliderContainer) {
sliderContainer.addEventListener('mouseenter', () => {
if (beatportHypePicksSliderState.autoPlayInterval) {
clearInterval(beatportHypePicksSliderState.autoPlayInterval);
}
});
sliderContainer.addEventListener('mouseleave', () => {
startBeatportHypePicksSliderAutoPlay();
});
}
}
/**
* Setup click handlers for hype pick cards
*/
function setupBeatportHypePickCardHandlers() {
const cards = document.querySelectorAll('.beatport-hype-pick-card:not(.beatport-hype-pick-placeholder)');
cards.forEach(card => {
const releaseUrl = card.getAttribute('data-url');
if (releaseUrl && releaseUrl !== '#' && releaseUrl !== '') {
// Extract release data from the card elements
const titleElement = card.querySelector('.beatport-hype-pick-title');
const artistElement = card.querySelector('.beatport-hype-pick-artist');
const labelElement = card.querySelector('.beatport-hype-pick-label');
const imageElement = card.querySelector('.beatport-hype-pick-artwork img');
const releaseData = {
url: releaseUrl,
title: titleElement ? titleElement.textContent.trim() : 'Unknown Title',
artist: artistElement ? artistElement.textContent.trim() : 'Unknown Artist',
label: labelElement ? labelElement.textContent.trim() : 'Unknown Label',
image_url: imageElement ? imageElement.src : ''
};
card.addEventListener('click', () => handleBeatportReleaseCardClick(card, releaseData));
card.style.cursor = 'pointer';
}
});
}
/**
* Show error state for hype picks slider
*/
function showBeatportHypePicksError(errorMessage) {
const sliderTrack = document.getElementById('beatport-hype-picks-slider-track');
if (sliderTrack) {
sliderTrack.innerHTML = `
❌ Error Loading Hype Picks
${errorMessage}
`;
}
}
/**
* Clean up hype picks slider when switching away
*/
function cleanupBeatportHypePicksSlider() {
if (beatportHypePicksSliderState.autoPlayInterval) {
clearInterval(beatportHypePicksSliderState.autoPlayInterval);
beatportHypePicksSliderState.autoPlayInterval = null;
}
}
// ===================================
// BEATPORT FEATURED CHARTS SLIDER
// ===================================
// State management for featured charts slider (copied from releases slider)
let beatportChartsSliderState = {
currentSlide: 0,
totalSlides: 0,
autoPlayInterval: null,
autoPlayDelay: 10000, // Slightly longer auto-play for charts
isInitialized: false
};
/**
* Initialize the beatport featured charts slider functionality (based on releases slider)
*/
function initializeBeatportChartsSlider() {
console.log('🔥 Initializing beatport featured charts slider...');
const slider = document.getElementById('beatport-charts-slider');
if (!slider) {
console.warn('Beatport charts slider not found');
return;
}
// Prevent double initialization
if (slider.dataset.initialized === 'true') {
console.log('Charts slider already initialized');
return;
}
const sliderTrack = document.getElementById('beatport-charts-slider-track');
const indicatorsContainer = document.getElementById('beatport-charts-slider-indicators');
if (!sliderTrack || !indicatorsContainer) {
console.warn('Charts slider elements not found');
return;
}
// Load data and initialize
loadBeatportFeaturedCharts().then(success => {
if (success) {
setupBeatportChartsSliderNavigation();
setupBeatportChartsSliderIndicators();
setupBeatportChartsSliderHoverPause();
startBeatportChartsSliderAutoPlay();
slider.dataset.initialized = 'true';
beatportChartsSliderState.isInitialized = true;
console.log('✅ Featured charts slider initialized successfully');
}
});
}
/**
* Load featured charts data from API
*/
async function loadBeatportFeaturedCharts() {
try {
console.log('📊 Loading featured charts data...');
const response = await fetch('/api/beatport/featured-charts');
const data = await response.json();
if (data.success && data.charts && data.charts.length > 0) {
console.log(`📈 Loaded ${data.charts.length} featured charts`);
createBeatportChartsSlides(data.charts);
return true;
} else {
console.warn('No featured charts data available');
return false;
}
} catch (error) {
console.error('❌ Error loading featured charts:', error);
return false;
}
}
/**
* Create chart slides with grid layout (copied from releases slider)
*/
function createBeatportChartsSlides(charts) {
const sliderTrack = document.getElementById('beatport-charts-slider-track');
const indicatorsContainer = document.getElementById('beatport-charts-slider-indicators');
if (!sliderTrack || !indicatorsContainer) {
console.error('Charts slider elements not found');
return;
}
const cardsPerSlide = 10; // 5x2 grid
const totalSlides = Math.ceil(charts.length / cardsPerSlide);
// Clear existing content
sliderTrack.innerHTML = '';
indicatorsContainer.innerHTML = '';
// Update state
beatportChartsSliderState.totalSlides = totalSlides;
beatportChartsSliderState.currentSlide = 0;
console.log(`🎯 Creating ${totalSlides} chart slides with ${cardsPerSlide} cards each`);
// Generate slides HTML
for (let slideIndex = 0; slideIndex < totalSlides; slideIndex++) {
const startIndex = slideIndex * cardsPerSlide;
const endIndex = Math.min(startIndex + cardsPerSlide, charts.length);
const slideCharts = charts.slice(startIndex, endIndex);
// Create grid HTML for this slide
const gridHtml = slideCharts.map(chart => {
const bgImageStyle = chart.image ? `--chart-bg-image: url('${chart.image}')` : '';
return `
${chart.name || 'Unknown Chart'}
${chart.creator || 'Unknown Creator'}
`;
}).join('');
// Create slide HTML
const slideHtml = `
${gridHtml}
`;
sliderTrack.innerHTML += slideHtml;
// Create indicator
const indicatorHtml = ``;
indicatorsContainer.innerHTML += indicatorHtml;
}
console.log(`✅ Created ${totalSlides} chart slides`);
// Add click handlers for individual chart discovery (matching chart pattern)
const chartCards = sliderTrack.querySelectorAll('.beatport-chart-card[data-url]');
chartCards.forEach((card) => {
const chartUrl = card.getAttribute('data-url');
if (chartUrl && chartUrl !== '') {
// Find the corresponding chart data
const chartData = charts.find(chart => chart.url === chartUrl);
if (chartData) {
card.addEventListener('click', () => handleBeatportChartCardClick(card, chartData));
card.style.cursor = 'pointer';
}
}
});
}
/**
* Set up navigation functionality (copied from releases slider with button cloning)
*/
function setupBeatportChartsSliderNavigation() {
const prevBtn = document.getElementById('beatport-charts-prev-btn');
const nextBtn = document.getElementById('beatport-charts-next-btn');
if (prevBtn) {
// Clone button to remove all existing event listeners
const newPrevBtn = prevBtn.cloneNode(true);
prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn);
newPrevBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('Previous charts button clicked, current slide:', beatportChartsSliderState.currentSlide);
goToBeatportChartsSlide(beatportChartsSliderState.currentSlide - 1);
resetBeatportChartsSliderAutoPlay();
});
}
if (nextBtn) {
// Clone button to remove all existing event listeners
const newNextBtn = nextBtn.cloneNode(true);
nextBtn.parentNode.replaceChild(newNextBtn, nextBtn);
newNextBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('Next charts button clicked, current slide:', beatportChartsSliderState.currentSlide);
goToBeatportChartsSlide(beatportChartsSliderState.currentSlide + 1);
resetBeatportChartsSliderAutoPlay();
});
}
}
/**
* Set up indicator functionality (copied from releases slider)
*/
function setupBeatportChartsSliderIndicators() {
const indicators = document.querySelectorAll('.beatport-charts-indicator');
indicators.forEach((indicator, index) => {
indicator.addEventListener('click', () => {
goToBeatportChartsSlide(index);
resetBeatportChartsSliderAutoPlay();
});
});
}
/**
* Navigate to a specific slide (copied from releases slider)
*/
function goToBeatportChartsSlide(slideIndex) {
console.log('goToBeatportChartsSlide called with:', slideIndex, 'current:', beatportChartsSliderState.currentSlide);
// Wrap around if out of bounds
if (slideIndex < 0) {
slideIndex = beatportChartsSliderState.totalSlides - 1;
} else if (slideIndex >= beatportChartsSliderState.totalSlides) {
slideIndex = 0;
}
console.log('After wrapping, slideIndex:', slideIndex);
// Update current slide
beatportChartsSliderState.currentSlide = slideIndex;
// Update slide visibility
const slides = document.querySelectorAll('.beatport-charts-slide');
slides.forEach((slide, index) => {
slide.classList.remove('active', 'prev', 'next');
if (index === slideIndex) {
slide.classList.add('active');
} else if (index < slideIndex) {
slide.classList.add('prev');
} else {
slide.classList.add('next');
}
});
// Update indicators
const indicators = document.querySelectorAll('.beatport-charts-indicator');
indicators.forEach((indicator, index) => {
indicator.classList.toggle('active', index === slideIndex);
});
console.log('Charts slide updated to:', beatportChartsSliderState.currentSlide);
}
/**
* Start auto-play functionality (copied from releases slider)
*/
function startBeatportChartsSliderAutoPlay() {
if (beatportChartsSliderState.autoPlayInterval) {
clearInterval(beatportChartsSliderState.autoPlayInterval);
}
beatportChartsSliderState.autoPlayInterval = setInterval(() => {
goToBeatportChartsSlide(beatportChartsSliderState.currentSlide + 1);
}, beatportChartsSliderState.autoPlayDelay);
}
/**
* Reset auto-play timer (copied from releases slider)
*/
function resetBeatportChartsSliderAutoPlay() {
startBeatportChartsSliderAutoPlay();
}
/**
* Set up hover pause functionality (copied from releases slider)
*/
function setupBeatportChartsSliderHoverPause() {
const sliderContainer = document.querySelector('.beatport-charts-slider-container');
if (sliderContainer) {
sliderContainer.addEventListener('mouseenter', () => {
if (beatportChartsSliderState.autoPlayInterval) {
clearInterval(beatportChartsSliderState.autoPlayInterval);
beatportChartsSliderState.autoPlayInterval = null;
}
});
sliderContainer.addEventListener('mouseleave', () => {
startBeatportChartsSliderAutoPlay();
});
}
}
/**
* Clean up charts slider when switching away (copied from releases slider)
*/
function cleanupBeatportChartsSlider() {
if (beatportChartsSliderState.autoPlayInterval) {
clearInterval(beatportChartsSliderState.autoPlayInterval);
beatportChartsSliderState.autoPlayInterval = null;
}
}
// ===================================
// BEATPORT DJ CHARTS SLIDER
// ===================================
// State management for DJ charts slider (3 cards per slide)
let beatportDJSliderState = {
currentSlide: 0,
totalSlides: 0,
autoPlayInterval: null,
autoPlayDelay: 12000, // Longer auto-play for DJ charts
isInitialized: false
};
/**
* Initialize the beatport DJ charts slider functionality (based on charts slider)
*/
function initializeBeatportDJSlider() {
console.log('🎧 Initializing beatport DJ charts slider...');
const slider = document.getElementById('beatport-dj-slider');
if (!slider) {
console.warn('Beatport DJ slider not found');
return;
}
// Prevent double initialization
if (slider.dataset.initialized === 'true') {
console.log('DJ slider already initialized');
return;
}
const sliderTrack = document.getElementById('beatport-dj-slider-track');
const indicatorsContainer = document.getElementById('beatport-dj-slider-indicators');
if (!sliderTrack || !indicatorsContainer) {
console.warn('DJ slider elements not found');
return;
}
// Load data and initialize
loadBeatportDJCharts().then(success => {
if (success) {
setupBeatportDJSliderNavigation();
setupBeatportDJSliderIndicators();
setupBeatportDJSliderHoverPause();
startBeatportDJSliderAutoPlay();
slider.dataset.initialized = 'true';
beatportDJSliderState.isInitialized = true;
console.log('✅ DJ charts slider initialized successfully');
}
});
}
/**
* Load DJ charts data from API
*/
async function loadBeatportDJCharts() {
try {
console.log('🎧 Loading DJ charts data...');
const response = await fetch('/api/beatport/dj-charts');
const data = await response.json();
if (data.success && data.charts && data.charts.length > 0) {
console.log(`📈 Loaded ${data.charts.length} DJ charts`);
createBeatportDJSlides(data.charts);
return true;
} else {
console.warn('No DJ charts data available');
return false;
}
} catch (error) {
console.error('❌ Error loading DJ charts:', error);
return false;
}
}
/**
* Create DJ chart slides with 3 cards per slide layout
*/
function createBeatportDJSlides(charts) {
const sliderTrack = document.getElementById('beatport-dj-slider-track');
const indicatorsContainer = document.getElementById('beatport-dj-slider-indicators');
if (!sliderTrack || !indicatorsContainer) {
console.error('DJ slider elements not found');
return;
}
const cardsPerSlide = 3; // 3 cards per slide for DJ charts
const totalSlides = Math.ceil(charts.length / cardsPerSlide);
// Clear existing content
sliderTrack.innerHTML = '';
indicatorsContainer.innerHTML = '';
// Update state
beatportDJSliderState.totalSlides = totalSlides;
beatportDJSliderState.currentSlide = 0;
console.log(`🎯 Creating ${totalSlides} DJ chart slides with ${cardsPerSlide} cards each`);
// Generate slides HTML
for (let slideIndex = 0; slideIndex < totalSlides; slideIndex++) {
const startIndex = slideIndex * cardsPerSlide;
const endIndex = Math.min(startIndex + cardsPerSlide, charts.length);
const slideCharts = charts.slice(startIndex, endIndex);
// Create grid HTML for this slide
const gridHtml = slideCharts.map(chart => {
const bgImageStyle = chart.image ? `--dj-bg-image: url('${chart.image}')` : '';
return `
${chart.name || 'Unknown Chart'}
${chart.creator || 'Unknown Creator'}
`;
}).join('');
// Create slide HTML
const slideHtml = `
${gridHtml}
`;
sliderTrack.innerHTML += slideHtml;
// Create indicator
const indicatorHtml = ``;
indicatorsContainer.innerHTML += indicatorHtml;
}
console.log(`✅ Created ${totalSlides} DJ chart slides`);
// Add click handlers for individual DJ chart discovery (matching chart pattern)
const djChartCards = sliderTrack.querySelectorAll('.beatport-dj-card[data-url]');
djChartCards.forEach((card) => {
const chartUrl = card.getAttribute('data-url');
if (chartUrl && chartUrl !== '') {
// Find the corresponding chart data
const chartData = charts.find(chart => chart.url === chartUrl);
if (chartData) {
card.addEventListener('click', () => handleBeatportDJChartCardClick(card, chartData));
card.style.cursor = 'pointer';
}
}
});
}
/**
* Set up navigation functionality (copied from charts slider with button cloning)
*/
function setupBeatportDJSliderNavigation() {
const prevBtn = document.getElementById('beatport-dj-prev-btn');
const nextBtn = document.getElementById('beatport-dj-next-btn');
if (prevBtn) {
// Clone button to remove all existing event listeners
const newPrevBtn = prevBtn.cloneNode(true);
prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn);
newPrevBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('Previous DJ button clicked, current slide:', beatportDJSliderState.currentSlide);
goToBeatportDJSlide(beatportDJSliderState.currentSlide - 1);
resetBeatportDJSliderAutoPlay();
});
}
if (nextBtn) {
// Clone button to remove all existing event listeners
const newNextBtn = nextBtn.cloneNode(true);
nextBtn.parentNode.replaceChild(newNextBtn, nextBtn);
newNextBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('Next DJ button clicked, current slide:', beatportDJSliderState.currentSlide);
goToBeatportDJSlide(beatportDJSliderState.currentSlide + 1);
resetBeatportDJSliderAutoPlay();
});
}
}
/**
* Set up indicator functionality (copied from charts slider)
*/
function setupBeatportDJSliderIndicators() {
const indicators = document.querySelectorAll('.beatport-dj-indicator');
indicators.forEach((indicator, index) => {
indicator.addEventListener('click', () => {
goToBeatportDJSlide(index);
resetBeatportDJSliderAutoPlay();
});
});
}
/**
* Navigate to a specific slide (copied from charts slider)
*/
function goToBeatportDJSlide(slideIndex) {
console.log('goToBeatportDJSlide called with:', slideIndex, 'current:', beatportDJSliderState.currentSlide);
// Wrap around if out of bounds
if (slideIndex < 0) {
slideIndex = beatportDJSliderState.totalSlides - 1;
} else if (slideIndex >= beatportDJSliderState.totalSlides) {
slideIndex = 0;
}
console.log('After wrapping, slideIndex:', slideIndex);
// Update current slide
beatportDJSliderState.currentSlide = slideIndex;
// Update slide visibility
const slides = document.querySelectorAll('.beatport-dj-slide');
slides.forEach((slide, index) => {
slide.classList.remove('active', 'prev', 'next');
if (index === slideIndex) {
slide.classList.add('active');
} else if (index < slideIndex) {
slide.classList.add('prev');
} else {
slide.classList.add('next');
}
});
// Update indicators
const indicators = document.querySelectorAll('.beatport-dj-indicator');
indicators.forEach((indicator, index) => {
indicator.classList.toggle('active', index === slideIndex);
});
console.log('DJ slide updated to:', beatportDJSliderState.currentSlide);
}
/**
* Start auto-play functionality (copied from charts slider)
*/
function startBeatportDJSliderAutoPlay() {
if (beatportDJSliderState.autoPlayInterval) {
clearInterval(beatportDJSliderState.autoPlayInterval);
}
beatportDJSliderState.autoPlayInterval = setInterval(() => {
goToBeatportDJSlide(beatportDJSliderState.currentSlide + 1);
}, beatportDJSliderState.autoPlayDelay);
}
/**
* Reset auto-play timer (copied from charts slider)
*/
function resetBeatportDJSliderAutoPlay() {
startBeatportDJSliderAutoPlay();
}
/**
* Set up hover pause functionality (copied from charts slider)
*/
function setupBeatportDJSliderHoverPause() {
const sliderContainer = document.querySelector('.beatport-dj-slider-container');
if (sliderContainer) {
sliderContainer.addEventListener('mouseenter', () => {
if (beatportDJSliderState.autoPlayInterval) {
clearInterval(beatportDJSliderState.autoPlayInterval);
beatportDJSliderState.autoPlayInterval = null;
}
});
sliderContainer.addEventListener('mouseleave', () => {
startBeatportDJSliderAutoPlay();
});
}
}
/**
* Clean up DJ slider when switching away (copied from charts slider)
*/
function cleanupBeatportDJSlider() {
if (beatportDJSliderState.autoPlayInterval) {
clearInterval(beatportDJSliderState.autoPlayInterval);
beatportDJSliderState.autoPlayInterval = null;
}
}
/**
* Load top 10 lists data from API and populate both lists
*/
async function loadBeatportTop10Lists() {
try {
console.log('🏆 Loading top 10 lists data...');
const response = await fetch('/api/beatport/homepage/top-10-lists');
const data = await response.json();
if (data.success) {
console.log(`🎵 Loaded ${data.beatport_count} Beatport Top 10 + ${data.hype_count} Hype Top 10 tracks`);
// Populate both lists
populateBeatportTop10List(data.beatport_top10);
populateHypeTop10List(data.hype_top10);
return true;
} else {
console.error('Failed to load top 10 lists:', data.error);
showTop10ListsError(data.error || 'No data available');
return false;
}
} catch (error) {
console.error('Error loading top 10 lists:', error);
showTop10ListsError('Failed to load top 10 lists');
return false;
}
}
/**
* Clean track/artist text for proper spacing
*/
function cleanTrackText(text) {
if (!text) return text;
// Fix common spacing issues
text = text.replace(/([a-z$!@#%&*])([A-Z])/g, '$1 $2'); // Add space between lowercase/symbols and uppercase
text = text.replace(/([a-zA-Z]),([a-zA-Z])/g, '$1, $2'); // Add space after comma
text = text.replace(/([a-zA-Z])(Mix|Remix|Extended|Version)\b/g, '$1 $2'); // Fix mix types
text = text.replace(/\s+/g, ' '); // Collapse multiple spaces
text = text.trim();
return text;
}
/**
* Populate Beatport Top 10 list with data
*/
function populateBeatportTop10List(tracks) {
const container = document.getElementById('beatport-top10-list');
if (!container || !tracks || tracks.length === 0) return;
// Generate HTML for the tracks
let tracksHtml = `
🎵 Beatport Top 10
Most popular tracks on Beatport
`;
tracks.forEach((track, index) => {
// Clean the text data before injection
const cleanTitle = cleanTrackText(track.title || 'Unknown Title');
const cleanArtist = cleanTrackText(track.artist || 'Unknown Artist');
const cleanLabel = cleanTrackText(track.label || 'Unknown Label');
tracksHtml += `
${track.rank || index + 1}
${track.artwork_url ?
`` :
'
🎵
'
}
${cleanTitle}
${cleanArtist}
${cleanLabel}
`;
});
tracksHtml += '
';
container.innerHTML = tracksHtml;
}
/**
* Populate Hype Top 10 list with data
*/
function populateHypeTop10List(tracks) {
const container = document.getElementById('beatport-hype10-list');
if (!container || !tracks || tracks.length === 0) return;
// Generate HTML for the tracks
let tracksHtml = `
🔥 Hype Top 10
Editor's trending picks
`;
tracks.forEach((track, index) => {
// Clean the text data before injection
const cleanTitle = cleanTrackText(track.title || 'Unknown Title');
const cleanArtist = cleanTrackText(track.artist || 'Unknown Artist');
const cleanLabel = cleanTrackText(track.label || 'Unknown Label');
tracksHtml += `
${track.rank || index + 1}
${track.artwork_url ?
`` :
'
🔥
'
}
${cleanTitle}
${cleanArtist}
${cleanLabel}
`;
});
tracksHtml += '
';
container.innerHTML = tracksHtml;
}
/**
* Show error message for top 10 lists
*/
function showTop10ListsError(errorMessage) {
const beatportContainer = document.getElementById('beatport-top10-list');
const hypeContainer = document.getElementById('beatport-hype10-list');
const errorHtml = `
❌ Error Loading Data
${errorMessage}
`;
if (beatportContainer) beatportContainer.innerHTML = errorHtml;
if (hypeContainer) hypeContainer.innerHTML = errorHtml;
}
/**
* Load top 10 releases data from API and populate the list
*/
async function loadBeatportTop10Releases() {
try {
console.log('💿 Loading top 10 releases data...');
const response = await fetch('/api/beatport/homepage/top-10-releases-cards');
const data = await response.json();
if (data.success) {
console.log(`💿 Loaded ${data.releases_count} Top 10 Releases`);
populateBeatportTop10Releases(data.releases);
return true;
} else {
console.error('Failed to load top 10 releases:', data.error);
showTop10ReleasesError(data.error || 'No data available');
return false;
}
} catch (error) {
console.error('Error loading top 10 releases:', error);
showTop10ReleasesError('Failed to load top 10 releases');
return false;
}
}
/**
* Populate Top 10 Releases list with data
*/
function populateBeatportTop10Releases(releases) {
const container = document.getElementById('beatport-releases-top10-list');
if (!container || !releases || releases.length === 0) return;
// Generate HTML for the releases
let releasesHtml = `
`;
}
function addGenreHeroReleaseClickHandlers(releases) {
// Clear any existing intervals first
if (window.genreHeroSliderState && window.genreHeroSliderState.autoPlayInterval) {
clearInterval(window.genreHeroSliderState.autoPlayInterval);
console.log('🧹 Cleared previous genre hero auto-play interval');
}
// CRITICAL: Clear ALL possible conflicting intervals
if (typeof beatportRebuildSliderState !== 'undefined' && beatportRebuildSliderState.autoPlayInterval) {
clearInterval(beatportRebuildSliderState.autoPlayInterval);
console.log('🛑 Cleared main rebuild slider auto-play interval');
}
// Initialize global slider state for genre hero slider
window.genreHeroSliderState = {
currentSlide: 0,
totalSlides: releases.length,
autoPlayInterval: null
};
console.log(`🎠 Initializing genre hero slider with ${releases.length} slides`);
// Set up navigation button handlers
const prevBtn = document.getElementById('genre-hero-prev-btn');
const nextBtn = document.getElementById('genre-hero-next-btn');
if (prevBtn) {
prevBtn.addEventListener('click', () => {
window.genreHeroSliderState.currentSlide = window.genreHeroSliderState.currentSlide > 0
? window.genreHeroSliderState.currentSlide - 1
: window.genreHeroSliderState.totalSlides - 1;
updateGenreHeroSlide(window.genreHeroSliderState.currentSlide);
console.log(`⬅️ Previous: Moving to slide ${window.genreHeroSliderState.currentSlide}`);
});
}
if (nextBtn) {
nextBtn.addEventListener('click', () => {
window.genreHeroSliderState.currentSlide = (window.genreHeroSliderState.currentSlide + 1) % window.genreHeroSliderState.totalSlides;
updateGenreHeroSlide(window.genreHeroSliderState.currentSlide);
console.log(`➡️ Next: Moving to slide ${window.genreHeroSliderState.currentSlide}`);
});
}
// Set up indicator handlers
const indicators = document.querySelectorAll('#genre-hero-slider .beatport-rebuild-indicator');
indicators.forEach((indicator, index) => {
indicator.addEventListener('click', () => {
window.genreHeroSliderState.currentSlide = index;
updateGenreHeroSlide(index);
console.log(`🎯 Indicator: Jumping to slide ${index}`);
});
});
// Set up individual slide click handlers (like the main hero slider)
const slides = document.querySelectorAll('#genre-hero-slider .beatport-rebuild-slide[data-url]');
console.log(`🔗 Found ${slides.length} slides to set up click handlers for`);
slides.forEach((slide, index) => {
const releaseUrl = slide.getAttribute('data-url');
if (releaseUrl && releaseUrl !== '#' && releaseUrl !== '') {
const release = releases[index];
if (release) {
// Ensure we use the absolute URL and match the expected data structure
const releaseData = {
url: releaseUrl, // This is already the absolute URL from data-url
title: release.title || 'Unknown Title',
artist: release.artists_string || 'Unknown Artist', // handleBeatportReleaseCardClick expects 'artist'
label: release.label || 'Unknown Label',
image_url: release.image_url || '',
// Include all original data for completeness
artists_string: release.artists_string,
type: release.type,
source: release.source,
badges: release.badges || []
};
slide.addEventListener('click', async (event) => {
// Prevent navigation button clicks from triggering this
if (event.target.closest('.beatport-rebuild-nav-btn') ||
event.target.closest('.beatport-rebuild-indicator')) {
return;
}
console.log(`🎵 Genre hero slide clicked: ${releaseData.title} by ${releaseData.artist}`);
// Use the exact same functionality as the main hero slider
await handleBeatportReleaseCardClick(slide, releaseData);
});
slide.style.cursor = 'pointer';
}
}
});
// Ensure first slide is active BEFORE starting auto-play
updateGenreHeroSlide(0);
// Delay auto-play start to let DOM settle
setTimeout(() => {
startGenreHeroSliderAutoPlay();
}, 100);
// Pause on hover
const sliderContainer = document.querySelector('#genre-hero-slider');
if (sliderContainer) {
sliderContainer.addEventListener('mouseenter', () => {
if (window.genreHeroSliderState.autoPlayInterval) {
clearInterval(window.genreHeroSliderState.autoPlayInterval);
console.log('⏸️ Paused auto-play on hover');
}
});
sliderContainer.addEventListener('mouseleave', () => {
// Delay restart to avoid rapid state changes
setTimeout(() => {
startGenreHeroSliderAutoPlay();
}, 100);
console.log('▶️ Resumed auto-play after hover');
});
}
console.log(`✅ Set up slider functionality for ${releases.length} genre hero releases`);
}
function updateGenreHeroSlide(slideIndex) {
if (!window.genreHeroSliderState) {
console.error('❌ Genre hero slider state not initialized');
return;
}
// First update the state
window.genreHeroSliderState.currentSlide = slideIndex;
// Update slide visibility - use the exact same logic as main slider
const slides = document.querySelectorAll('#genre-hero-slider .beatport-rebuild-slide');
console.log(`🔄 Updating slide to index ${slideIndex}, found ${slides.length} slides`);
if (slideIndex >= slides.length || slideIndex < 0) {
console.error(`❌ Invalid slide index ${slideIndex}, max is ${slides.length - 1}`);
return;
}
slides.forEach((slide, index) => {
slide.classList.remove('active', 'prev', 'next');
if (index === slideIndex) {
slide.classList.add('active');
console.log(`✅ Activated slide ${index}: ${slide.getAttribute('data-slide')} - Title: ${slide.querySelector('.beatport-rebuild-track-title')?.textContent}`);
} else if (index < slideIndex) {
slide.classList.add('prev');
} else {
slide.classList.add('next');
}
});
// Update indicators
const indicators = document.querySelectorAll('#genre-hero-slider .beatport-rebuild-indicator');
indicators.forEach((indicator, index) => {
indicator.classList.toggle('active', index === slideIndex);
});
console.log(`Genre slide updated to: ${window.genreHeroSliderState.currentSlide}`);
}
function startGenreHeroSliderAutoPlay() {
if (!window.genreHeroSliderState) {
console.error('❌ Cannot start auto-play: Genre hero slider state not initialized');
return;
}
// Clear any existing intervals first
if (window.genreHeroSliderState.autoPlayInterval) {
clearInterval(window.genreHeroSliderState.autoPlayInterval);
console.log('🧹 Cleared existing auto-play interval');
}
window.genreHeroSliderState.autoPlayInterval = setInterval(() => {
if (!window.genreHeroSliderState) {
console.error('❌ Auto-play fired but state is gone, clearing interval');
clearInterval(window.genreHeroSliderState.autoPlayInterval);
return;
}
const currentSlide = window.genreHeroSliderState.currentSlide;
const totalSlides = window.genreHeroSliderState.totalSlides;
const nextSlide = (currentSlide + 1) % totalSlides;
console.log(`⏰ Auto-play: Current=${currentSlide}, Total=${totalSlides}, Next=${nextSlide}`);
// Validate the next slide index
if (nextSlide >= 0 && nextSlide < totalSlides) {
updateGenreHeroSlide(nextSlide);
} else {
console.error(`❌ Invalid nextSlide calculated: ${nextSlide}, resetting to 0`);
updateGenreHeroSlide(0);
}
}, 5000); // 5 second intervals like the main slider
console.log(`▶️ Started auto-play for genre hero slider (${window.genreHeroSliderState.totalSlides} slides)`);
}
/**
* Load Top 10 lists for a specific genre (Beatport + Hype)
*/
async function loadGenreTop10Lists(genreSlug, genreId, genreName) {
console.log(`🎵 Loading Top 10 lists for ${genreName}...`);
const container = document.getElementById('genre-top10-lists-container');
if (!container) {
console.error('❌ Genre Top 10 lists container not found');
return;
}
try {
const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/top-10-lists`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load Top 10 lists');
}
console.log(`✅ Loaded ${data.beatport_count} Beatport + ${data.hype_count} Hype Top 10 tracks for ${genreName}`);
// Generate HTML using exact same structure as main page (but unique IDs)
const top10ListsHTML = createGenreTop10ListsHTML(data, genreName);
container.innerHTML = top10ListsHTML;
// Add container-level click handlers exactly like main page
addGenreTop10ClickHandlers();
console.log(`✅ Successfully populated genre Top 10 lists for ${genreName}`);
} catch (error) {
console.error(`❌ Error loading Top 10 lists for ${genreName}:`, error);
// Show error state
container.innerHTML = `
❌ Error Loading Top 10 Lists
Could not load Top 10 tracks for ${genreName}
${error.message}
`;
}
}
/**
* Create HTML for genre Top 10 lists (exact structure as main page, unique IDs)
*/
function createGenreTop10ListsHTML(data, genreName) {
const { beatport_top10, hype_top10, has_hype_section } = data;
// Use exact same structure as main page but with genre-specific IDs
let html = `
🏆 ${genreName} Top 10 Lists
Current trending ${genreName.toLowerCase()} tracks
🎵 Beatport Top 10
Most popular ${genreName.toLowerCase()} tracks
`;
// Add Beatport Top 10 tracks (same classes as main page)
beatport_top10.forEach((track, index) => {
const cleanTitle = cleanTrackText(track.title || 'Unknown Title');
const cleanArtist = cleanTrackText(track.artist || 'Unknown Artist');
const cleanLabel = cleanTrackText(track.label || 'Unknown Label');
html += `
${track.rank || index + 1}
${track.artwork_url ?
`` :
'
🎵
'
}
${cleanTitle}
${cleanArtist}
${cleanLabel}
`;
});
html += `
`;
// Add Hype Top 10 section (same classes, unique ID)
if (has_hype_section && hype_top10.length > 0) {
html += `
${artists.map(artist => {
const genreTags = (artist.genres || []).slice(0, 3).map(g =>
`${escapeHtml(g)}`
).join('');
const similarText = artist.occurrence_count > 1
? `Similar to ${artist.occurrence_count} in your watchlist`
: 'Similar to an artist in your watchlist';
return `
${artist.image_url ? `
` : `
🎤
`}
${escapeHtml(artist.artist_name)}${similarText}
${genreTags}
`;
}).join('')}
`;
// Event delegation for card clicks and watchlist buttons
const grid = modal.querySelector('#recommended-artists-grid');
if (grid) {
grid.addEventListener('click', function(e) {
const watchlistBtn = e.target.closest('.recommended-card-watchlist-btn');
if (watchlistBtn) {
e.stopPropagation();
toggleRecommendedWatchlist(watchlistBtn);
return;
}
const card = e.target.closest('.recommended-artist-card');
if (card) {
const artistId = card.getAttribute('data-artist-id');
const nameEl = card.querySelector('.recommended-card-name');
const artistName = nameEl ? nameEl.textContent : '';
viewRecommendedArtistDiscography(artistId, artistName);
}
});
}
}
async function addAllRecommendedToWatchlist(btn) {
if (!_recommendedArtistsCache || _recommendedArtistsCache.length === 0) return;
if (btn.classList.contains('all-added')) return;
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Adding...';
try {
const artists = _recommendedArtistsCache.map(a => ({
artist_id: a.artist_id,
artist_name: a.artist_name
}));
const resp = await fetch('/api/watchlist/add-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artists })
});
const data = await resp.json();
if (data.success) {
btn.textContent = `All Added (${data.added} new)`;
btn.classList.add('all-added');
btn.disabled = true;
// Update all watchlist buttons in the modal to "Watching"
document.querySelectorAll('.recommended-card-watchlist-btn').forEach(wBtn => {
wBtn.classList.add('watching');
wBtn.textContent = 'Watching';
});
if (typeof updateWatchlistButtonCount === 'function') updateWatchlistButtonCount();
} else {
btn.textContent = originalText;
btn.disabled = false;
}
} catch (error) {
console.error('Error adding all recommended to watchlist:', error);
btn.textContent = originalText;
btn.disabled = false;
}
}
function closeRecommendedArtistsModal() {
const modal = document.getElementById('recommended-artists-modal');
if (modal) modal.style.display = 'none';
}
function filterRecommendedArtists() {
const query = (document.getElementById('recommended-search-input')?.value || '').toLowerCase();
const cards = document.querySelectorAll('.recommended-artist-card');
cards.forEach(card => {
const name = card.getAttribute('data-artist-name') || '';
card.style.display = name.includes(query) ? '' : 'none';
});
}
async function toggleRecommendedWatchlist(btn) {
const artistId = btn.getAttribute('data-artist-id');
const artistName = btn.getAttribute('data-artist-name');
if (!artistId || !artistName) return;
btn.disabled = true;
const wasWatching = btn.classList.contains('watching');
try {
if (wasWatching) {
const resp = await fetch('/api/watchlist/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const data = await resp.json();
if (data.success) {
btn.classList.remove('watching');
btn.textContent = 'Add to Watchlist';
}
} else {
const resp = await fetch('/api/watchlist/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId, artist_name: artistName })
});
const data = await resp.json();
if (data.success) {
btn.classList.add('watching');
btn.textContent = 'Watching';
}
}
if (typeof updateWatchlistButtonCount === 'function') updateWatchlistButtonCount();
} catch (error) {
console.error('Error toggling recommended watchlist:', error);
} finally {
btn.disabled = false;
}
}
async function checkRecommendedWatchlistStatuses(artists) {
try {
const artistIds = artists.map(a => a.artist_id).filter(Boolean);
if (!artistIds.length) return;
const resp = await fetch('/api/watchlist/check-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_ids: artistIds })
});
const data = await resp.json();
if (data.success && data.results) {
for (const [aid, isWatching] of Object.entries(data.results)) {
if (isWatching) {
const btn = document.querySelector(`.recommended-card-watchlist-btn[data-artist-id="${aid}"]`);
if (btn) {
btn.classList.add('watching');
btn.textContent = 'Watching';
}
}
}
}
} catch (e) {
// Non-critical
}
}
async function viewRecommendedArtistDiscography(artistId, artistName) {
closeRecommendedArtistsModal();
const artist = {
id: artistId,
name: artistName
};
// Use same navigation pattern as hero slider
navigateToPage('artists');
await new Promise(resolve => setTimeout(resolve, 100));
await selectArtistForDetail(artist);
}
async function checkAllHeroWatchlistStatus() {
const btn = document.getElementById('discover-hero-watch-all');
if (!btn || !discoverHeroArtists || discoverHeroArtists.length === 0) return;
try {
let allWatched = true;
for (const artist of discoverHeroArtists) {
const response = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artist.artist_id })
});
const data = await response.json();
if (!data.success || !data.is_watching) {
allWatched = false;
break;
}
}
const textEl = btn.querySelector('.watch-all-text');
if (allWatched) {
if (textEl) textEl.textContent = 'All Watched';
btn.classList.add('all-watched');
btn.disabled = true;
} else {
if (textEl) textEl.textContent = 'Watch All';
btn.classList.remove('all-watched');
btn.disabled = false;
}
} catch (error) {
console.error('Error checking hero watchlist status:', error);
}
}
function navigateDiscoverHero(direction) {
if (!discoverHeroArtists || discoverHeroArtists.length === 0) return;
// Update index with wrapping
discoverHeroIndex = (discoverHeroIndex + direction + discoverHeroArtists.length) % discoverHeroArtists.length;
// Display the artist
displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]);
// Update indicators
updateDiscoverHeroIndicators();
}
function updateDiscoverHeroIndicators() {
const indicatorsContainer = document.getElementById('discover-hero-indicators');
if (!indicatorsContainer || !discoverHeroArtists || discoverHeroArtists.length === 0) return;
// Create indicator dots
indicatorsContainer.innerHTML = discoverHeroArtists.map((_, index) => `
`).join('');
}
function jumpToDiscoverHeroSlide(index) {
if (!discoverHeroArtists || index < 0 || index >= discoverHeroArtists.length) return;
discoverHeroIndex = index;
displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]);
updateDiscoverHeroIndicators();
}
async function viewDiscoverHeroDiscography() {
const button = document.getElementById('discover-hero-discography');
if (!button) return;
const artistId = button.getAttribute('data-artist-id');
const artistName = button.getAttribute('data-artist-name');
if (!artistId || !artistName) {
console.error('No artist data found for discography view');
return;
}
// Create artist object matching the expected format
const artist = {
id: artistId,
name: artistName,
image_url: discoverHeroArtists[discoverHeroIndex]?.image_url || '',
genres: discoverHeroArtists[discoverHeroIndex]?.genres || [],
popularity: discoverHeroArtists[discoverHeroIndex]?.popularity || 0
};
console.log(`🎵 Navigating to artist detail for: ${artistName}`);
// Navigate to Artists page
navigateToPage('artists');
// Small delay to let the page load
await new Promise(resolve => setTimeout(resolve, 100));
// Load the artist details
await selectArtistForDetail(artist);
}
function showDiscoverHeroEmpty() {
const titleEl = document.getElementById('discover-hero-title');
const subtitleEl = document.getElementById('discover-hero-subtitle');
if (titleEl) titleEl.textContent = 'No Recommendations Yet';
if (subtitleEl) subtitleEl.textContent = 'Run a watchlist scan to generate personalized recommendations';
}
async function loadDiscoverRecentReleases() {
try {
const carousel = document.getElementById('recent-releases-carousel');
if (!carousel) return;
carousel.innerHTML = '
Loading recent releases...
';
const response = await fetch('/api/discover/recent-releases');
if (!response.ok) {
throw new Error('Failed to fetch recent releases');
}
const data = await response.json();
if (!data.success || !data.albums || data.albums.length === 0) {
carousel.innerHTML = '
No recent releases found
';
return;
}
// Store albums for download functionality
discoverRecentAlbums = data.albums;
// Build carousel HTML
let html = '';
data.albums.forEach((album, index) => {
const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
html += `
';
return;
}
// For recommendations tab with multiple playlists, group into sub-tabs
if (tabId === 'recommendations' && playlists.length > 1) {
const { groups, groupOrder } = groupListenBrainzPlaylists(playlists);
// If only one group, no need for sub-tabs
if (groupOrder.length <= 1) {
const html = buildListenBrainzPlaylistsHtml(playlists, tabId);
container.innerHTML = html;
loadTracksForPlaylists(playlists);
return;
}
// Build sub-tabs bar
const firstGroup = activeListenBrainzSubTab && groupOrder.includes(activeListenBrainzSubTab)
? activeListenBrainzSubTab
: groupOrder[0];
activeListenBrainzSubTab = firstGroup;
let subTabsHtml = '
`;
_insertCacheSection('cache-genre-explorer',
'Genre Explorer', 'Tap a genre to explore', html, 'top');
} catch (e) { console.debug('Cache genre explorer:', e); }
}
async function openGenreDeepDive(genre) {
document.getElementById('genre-deep-dive-modal')?.remove();
const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
const _fmtNum = (n) => {
if (!n) return '';
if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
if (n >= 1000) return (n/1000).toFixed(0) + 'K';
return n.toString();
};
const _fmtDur = (ms) => {
if (!ms) return '';
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
return `${m}:${s.toString().padStart(2, '0')}`;
};
const overlay = document.createElement('div');
overlay.id = 'genre-deep-dive-modal';
overlay.className = 'genre-dive-overlay';
overlay.innerHTML = `
Genre Deep Dive
${_esc(genre)}
Exploring ${_esc(genre)}...
`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
try {
const resp = await fetch(`/api/discover/genre-deep-dive?genre=${encodeURIComponent(genre)}`);
if (!resp.ok) throw new Error('Failed to load');
const data = await resp.json();
if (!data.success) throw new Error('Failed');
const body = document.getElementById('genre-dive-body');
if (!body) return;
// Update header with counts
const subtitle = document.querySelector('.genre-dive-subtitle');
if (subtitle) {
const parts = [];
if (data.artists?.length) parts.push(`${data.artists.length} artist${data.artists.length !== 1 ? 's' : ''}`);
if (data.tracks?.length) parts.push(`${data.tracks.length} track${data.tracks.length !== 1 ? 's' : ''}`);
if (data.albums?.length) parts.push(`${data.albums.length} album${data.albums.length !== 1 ? 's' : ''}`);
subtitle.textContent = parts.length ? parts.join(' · ') : 'Genre Deep Dive';
}
let html = '';
// Related genres — clickable pills that reload the modal
if (data.related_genres && data.related_genres.length) {
html += `
Related Genres
${data.related_genres.map(rg => `
`).join('')}
`;
}
// Artists section — clickable, navigates to artist page
// Uses library_id for in-library artists (source-agnostic), falls back to search by name
if (data.artists && data.artists.length) {
html += `
`;
document.body.appendChild(overlay);
}
async function saveRepairJobSettings(jobId) {
try {
const inputs = document.querySelectorAll(`.repair-setting-input[data-job="${jobId}"]`);
let intervalHours = null;
const settings = {};
inputs.forEach(input => {
const key = input.dataset.key;
if (key === '_interval_hours') {
intervalHours = parseInt(input.value) || 24;
} else {
if (input.type === 'checkbox') settings[key] = input.checked;
else if (input.type === 'number') settings[key] = parseFloat(input.value);
else settings[key] = input.value;
}
});
await fetch(`/api/repair/jobs/${jobId}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interval_hours: intervalHours, settings })
});
showToast('Settings saved', 'success');
} catch (error) {
console.error('Error saving job settings:', error);
showToast('Error saving settings', 'error');
}
}
async function runRepairJobNow(jobId) {
try {
await fetch(`/api/repair/jobs/${jobId}/run`, { method: 'POST' });
showToast('Job started', 'success');
setTimeout(() => loadRepairJobs(), 1000);
} catch (error) {
console.error('Error running job:', error);
showToast('Error starting job', 'error');
}
}
// ── Repair Job Live Progress ──
const _repairProgressLogCounts = {};
const _repairProgressHideTimers = {};
function updateRepairJobProgressFromData(data) {
for (const [jobId, state] of Object.entries(data)) {
const card = document.querySelector(`.repair-job-card[data-job-id="${jobId}"]`);
if (!card) continue;
// Update status dot
const statusDot = card.querySelector('.repair-job-status');
if (statusDot) {
if (state.status === 'running') statusDot.className = 'repair-job-status running';
else if (state.status === 'finished') statusDot.className = 'repair-job-status enabled';
else if (state.status === 'error') statusDot.className = 'repair-job-status enabled';
}
// Update flow badge to show running state
const firstBadge = card.querySelector('.repair-flow-badge.scan');
if (firstBadge) {
if (state.status === 'running') firstBadge.innerHTML = '▶ Running';
else if (state.status === 'finished') firstBadge.innerHTML = '✓ Complete';
else if (state.status === 'error') firstBadge.innerHTML = '✗ Error';
}
// Add/update card running class
card.classList.toggle('running', state.status === 'running');
card.classList.remove('disabled');
// Create or find progress panel (bar-first layout like automation)
let panel = card.querySelector('.repair-job-progress');
if (!panel) {
panel = document.createElement('div');
panel.className = 'repair-job-progress';
panel.innerHTML = `
`;
card.appendChild(panel);
}
// Show panel
panel.classList.add('visible');
panel.classList.toggle('finished', state.status === 'finished');
panel.classList.toggle('error', state.status === 'error');
if (state.status === 'running') {
panel.classList.remove('finished', 'error');
if (_repairProgressHideTimers[jobId]) {
clearTimeout(_repairProgressHideTimers[jobId]);
delete _repairProgressHideTimers[jobId];
}
// Reset log for re-run
if (_repairProgressLogCounts[jobId] > 0 && state.log && state.log.length < _repairProgressLogCounts[jobId]) {
const existingLog = panel.querySelector('.repair-progress-log');
if (existingLog) existingLog.innerHTML = '';
_repairProgressLogCounts[jobId] = 0;
}
}
// Update progress bar
const bar = panel.querySelector('.repair-progress-bar');
if (bar) bar.style.width = (state.progress || 0) + '%';
// Update phase
const phaseEl = panel.querySelector('.repair-progress-phase');
if (phaseEl && state.phase) phaseEl.textContent = state.phase;
// Update log
const logEl = panel.querySelector('.repair-progress-log');
if (logEl && state.log) {
const prevCount = _repairProgressLogCounts[jobId] || 0;
if (state.log.length > prevCount) {
const newLines = state.log.slice(prevCount);
for (const line of newLines) {
const div = document.createElement('div');
div.className = 'repair-log-line ' + (line.type || 'info');
div.textContent = line.text;
logEl.appendChild(div);
}
logEl.scrollTop = logEl.scrollHeight;
}
_repairProgressLogCounts[jobId] = state.log.length;
}
// Auto-hide panel after completion
if (state.status === 'finished' || state.status === 'error') {
if (!_repairProgressHideTimers[jobId]) {
_repairProgressHideTimers[jobId] = setTimeout(() => {
panel.classList.remove('visible');
card.classList.remove('running');
delete _repairProgressHideTimers[jobId];
delete _repairProgressLogCounts[jobId];
// Reload to get updated stats
loadRepairJobs();
}, 30000);
}
} else {
// Clear any existing hide timer if job restarts
if (_repairProgressHideTimers[jobId]) {
clearTimeout(_repairProgressHideTimers[jobId]);
delete _repairProgressHideTimers[jobId];
}
}
}
}
async function loadRepairFindingsDashboard() {
const dashboard = document.getElementById('repair-findings-dashboard');
if (!dashboard) return;
try {
const response = await fetch('/api/repair/findings/counts');
if (!response.ok) throw new Error('Failed to fetch counts');
const data = await response.json();
const pending = data.pending || 0;
const resolved = data.resolved || 0;
const dismissed = data.dismissed || 0;
const autoFixed = data.auto_fixed || 0;
const byJob = data.by_job || {};
// Summary stats row
let html = '
';
html += `
${pending.toLocaleString()} pending
`;
html += `
${resolved.toLocaleString()} resolved
`;
html += `
${dismissed.toLocaleString()} dismissed
`;
if (autoFixed > 0) {
html += `
${autoFixed.toLocaleString()} auto-fixed
`;
}
html += '
';
// Per-job chips (only if there are pending findings)
const jobIds = Object.keys(byJob).sort((a, b) => byJob[b].total - byJob[a].total);
if (jobIds.length > 0) {
html += '
';
const jobFilter = document.getElementById('repair-findings-job-filter');
const activeJob = jobFilter ? jobFilter.value : '';
for (const jid of jobIds) {
const job = byJob[jid];
const isActive = activeJob === jid;
const severityDots = [];
if (job.warning > 0) severityDots.push(``);
if (job.info > 0) severityDots.push(``);
html += `
';
if (albumUrl) {
const albumLabel = d.album_title || 'Album';
html += `
${_escFinding(albumLabel)}
`;
}
if (artistUrl) {
const artistLabel = d.artist_name || d.artist || 'Artist';
html += `
${_escFinding(artistLabel)}
`;
}
html += '
';
return html;
}
function _renderFindingDetail(f) {
const d = f.details || {};
const rows = [];
const media = _renderFindingMedia(d);
switch (f.finding_type) {
case 'dead_file':
if (d.artist) rows.push(['Artist', d.artist]);
if (d.album) rows.push(['Album', d.album]);
if (d.title) rows.push(['Title', d.title]);
if (d.track_id) rows.push(['Track ID', d.track_id]);
if (d.original_path) rows.push(['Original Path', d.original_path, 'path']);
return media + _gridRows(rows) + _renderPlayButton(f);
case 'orphan_file':
if (d.folder) rows.push(['Folder', d.folder, 'path']);
if (d.format) rows.push(['Format', d.format.toUpperCase()]);
if (d.file_size) rows.push(['File Size', _formatFileSize(d.file_size)]);
if (d.modified) rows.push(['Last Modified', d.modified]);
if (f.file_path) rows.push(['Full Path', f.file_path, 'path']);
return _gridRows(rows) + _renderPlayButton(f);
case 'acoustid_mismatch': {
let html = media + '
';
html += _renderScoreBar(d.fingerprint_score, 'Fingerprint');
html += _renderScoreBar(d.title_similarity, 'Title Match');
html += _renderScoreBar(d.artist_similarity, 'Artist Match');
html += '
`;
}
return incHtml;
case 'path_mismatch':
if (d.from) rows.push(['Current Path', d.from, 'path']);
if (d.to) rows.push(['Expected Path', d.to, 'success']);
return _gridRows(rows);
case 'metadata_gap':
if (d.artist) rows.push(['Artist', d.artist]);
if (d.album) rows.push(['Album', d.album]);
if (d.title) rows.push(['Title', d.title]);
if (d.spotify_track_id) rows.push(['Spotify ID', d.spotify_track_id]);
if (d.found_fields && typeof d.found_fields === 'object') {
Object.entries(d.found_fields).forEach(([k, v]) => {
rows.push([`Found: ${k}`, String(v), 'success']);
});
}
return media + _gridRows(rows);
case 'missing_cover_art':
if (d.artist) rows.push(['Artist', d.artist]);
if (d.album_title) rows.push(['Album', d.album_title]);
if (d.spotify_album_id) rows.push(['Spotify ID', d.spotify_album_id]);
let artHtml = '';
// Show artist image + found artwork side by side
if (d.artist_thumb_url || d.found_artwork_url) {
artHtml += '
';
if (d.artist_thumb_url) {
artHtml += `
${_escFinding(d.artist || 'Artist')}
`;
}
if (d.found_artwork_url) {
artHtml += `
Found Artwork
`;
}
artHtml += '
';
}
artHtml += _gridRows(rows);
return artHtml;
case 'track_number_mismatch':
if (d.album_title) rows.push(['Album', d.album_title]);
if (d.artist_name) rows.push(['Artist', d.artist_name]);
if (d.matched_title) rows.push(['Matched To', d.matched_title]);
if (d.file_title) rows.push(['File Title', d.file_title]);
if (d.current_track_num !== undefined) rows.push(['Current Track #', String(d.current_track_num)]);
if (d.correct_track_num !== undefined) rows.push(['Correct Track #', String(d.correct_track_num), 'success']);
if (f.file_path) rows.push(['File', f.file_path, 'path']);
let tnHtml = media;
if (d.match_score) {
tnHtml += '
`).join('');
}
// --- Initialization ---
function initializeImportPage() {
if (!importPageState.initialized) {
importPageState.initialized = true;
importPageRefreshStaging();
importPageLoadAutoGroups();
importPageLoadSuggestions();
}
}
async function importPageRefreshStaging() {
// Clear finished jobs from the queue
importPageClearFinishedJobs();
// Re-fetch groups and suggestions (server rebuilds cache after imports)
importPageLoadAutoGroups();
importPageLoadSuggestions();
try {
const resp = await fetch('/api/import/staging/files');
const data = await resp.json();
if (!data.success) {
document.getElementById('import-page-staging-path').textContent = `Staging folder: error`;
return;
}
importPageState.stagingFiles = data.files || [];
document.getElementById('import-page-staging-path').textContent = `Staging: ${data.staging_path || 'Not configured'}`;
const totalSize = importPageState.stagingFiles.reduce((s, f) => s + (f.size || 0), 0);
const sizeStr = totalSize > 1073741824 ? `${(totalSize / 1073741824).toFixed(1)} GB`
: totalSize > 1048576 ? `${(totalSize / 1048576).toFixed(0)} MB`
: `${(totalSize / 1024).toFixed(0)} KB`;
document.getElementById('import-page-staging-stats').textContent =
`${importPageState.stagingFiles.length} file${importPageState.stagingFiles.length !== 1 ? 's' : ''}${totalSize ? ' · ' + sizeStr : ''}`;
// Refresh the current tab view
if (importPageState.activeTab === 'singles') {
importPageRenderSinglesList();
}
} catch (err) {
console.error('Failed to refresh staging:', err);
}
}
function importPageSwitchTab(tab) {
importPageState.activeTab = tab;
document.getElementById('import-page-tab-album').classList.toggle('active', tab === 'album');
document.getElementById('import-page-tab-singles').classList.toggle('active', tab === 'singles');
document.getElementById('import-page-album-content').classList.toggle('active', tab === 'album');
document.getElementById('import-page-singles-content').classList.toggle('active', tab === 'singles');
if (tab === 'singles' && importPageState.stagingFiles.length > 0) {
importPageRenderSinglesList();
}
}
// --- Album Tab: Auto-Detected Groups (from file tags) ---
async function importPageLoadAutoGroups() {
const grid = document.getElementById('import-page-suggestions-grid');
if (!grid) return;
try {
const resp = await fetch('/api/import/staging/groups');
if (!resp.ok) return;
const data = await resp.json();
if (!data.success || !data.groups || data.groups.length === 0) return;
// Build auto-groups section above suggestions
let groupsContainer = document.getElementById('import-page-auto-groups');
if (!groupsContainer) {
groupsContainer = document.createElement('div');
groupsContainer.id = 'import-page-auto-groups';
groupsContainer.style.marginBottom = '16px';
const suggestionsSection = document.getElementById('import-page-suggestions');
if (suggestionsSection) {
suggestionsSection.parentNode.insertBefore(groupsContainer, suggestionsSection);
} else {
grid.parentNode.insertBefore(groupsContainer, grid);
}
}
groupsContainer.innerHTML = `
Auto-Detected Albums
${data.groups.map((g, idx) => `
${g.file_count}
${_esc(g.album)}
${_esc(g.artist)} · ${g.file_count} tracks
`).join('')}
`;
// Store groups for click handler
importPageState._autoGroups = data.groups;
} catch (err) {
console.warn('Failed to load auto-groups:', err);
}
}
async function importPageMatchAutoGroup(groupIdx) {
const group = importPageState._autoGroups?.[groupIdx];
if (!group) return;
// Search for the album by name + artist
const query = `${group.artist} ${group.album}`;
const searchInput = document.getElementById('import-page-album-search-input');
if (searchInput) searchInput.value = query;
// Hide suggestions/groups, show search results
const suggestionsEl = document.getElementById('import-page-suggestions');
const groupsEl = document.getElementById('import-page-auto-groups');
if (suggestionsEl) suggestionsEl.style.display = 'none';
if (groupsEl) groupsEl.style.display = 'none';
const grid = document.getElementById('import-page-album-results');
if (grid) grid.innerHTML = '
Searching...
';
try {
const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`);
const data = await resp.json();
if (data.success && data.albums && data.albums.length > 0) {
// Store file_paths filter so match only includes this group's files
importPageState._autoGroupFilePaths = group.file_paths;
// Render results — user picks the right album
grid.innerHTML = data.albums.map(a => _renderSuggestionCard(a)).join('');
} else {
grid.innerHTML = '
This pipeline deploys ${group.automations.length} automations${group.automations.some(a => a.then_actions.some(t => t.type === 'fire_signal')) ? ' linked by signals — each step triggers the next automatically' : ' running on independent schedules'}.
';
html += '' + statusLabel + '';
html += '' + timeAgo + '';
if (duration) html += '' + duration + '';
if (hasLogs) html += '▼';
html += '
';
if (summary) html += '
' + summary + '
';
if (entry.result_json && typeof entry.result_json === 'object') {
html += _renderResultStats(entry.result_json, actionType);
}
if (hasLogs) {
html += '
';
entry.log_lines.forEach(function(log) {
html += '
' + _esc(log.text || '') + '
';
});
html += '
';
}
html += '
';
});
html += '
';
if (data.total > data.history.length) {
html += '
`;
}
// Album meta line
const albumMetaParts = [];
if (albumYear) albumMetaParts.push(String(albumYear));
if (albumType) albumMetaParts.push(albumType.charAt(0).toUpperCase() + albumType.slice(1));
if (albumTrackCount) albumMetaParts.push(albumTrackCount + ' tracks');
if (albumLabel) albumMetaParts.push(albumLabel);
// For track issues, show the track title under the album
const trackNameLine = issue.entity_type === 'track' && entityName
? `